From f0b0234ac0ba06bbf371078578cbf5952bc09d35 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 20 Aug 2026 11:54:23 +0200 Subject: [PATCH] fix(ios): make the Home Screen widgets actually refresh, and state their age MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "The widgets don't update" was two independent bugs, neither of them the network. **The reload budget was being burned.** `WidgetPublisher` called `reloadTimelines(ofKind:)` inside each per-organization, per-surface publish — up to six a round — unconditionally, even when the fetch returned data identical to what was already stored. `reloadTimelines(ofKind:)` rebuilds *every* instance of that kind, so publishing organization B was also dragging organization A's pinned widget through a rebuild with no new data for it. iOS meters reloads, and the widgets' own timeline rebuilds come out of the same budget, so an ordinary day of opening the app exhausted it — after which the Home Screen only moved on its own `.after(+2h)` policy. Now: one reload per kind per round, and only for a kind whose numbers a reader could see change. Equality is the wrong test for that — `lastSeenAt`, `occurrenceCount`, and the throughput sparkline all move on every fetch without changing a glyph, so `!=` would report "changed" every round and buy nothing. `WidgetSnapshotContent.contentFingerprint` projects each snapshot through the same `WidgetFormat`/`WidgetTime` functions the views call, so the rule cannot drift from what is on screen without a test noticing. One deliberate exception, documented at the comparison: sparkline buckets are excluded (an hour-long series scrolling by one bucket is invisible), while `trend`, derived from them, is not. **Background wakes were silent no-ops once the app had been terminated.** `context` was set only from `MainTabView.task`, so a `BGAppRefreshTask` or a silent push that launches the app into the background — no view tree — found no context and returned at the first guard. The two triggers built precisely to keep a Home Screen current while the app is closed only ever worked when the app happened to still be alive in memory. `bootstrap(api:)`, called from `MapleApp.init`, assembles a context from Clerk's keychain-restored session and the App Group index; `Clerk.configure` hydrates synchronously, so no await is needed. Sign-out cannot resurrect it, because `clear()` empties the index the bootstrap reads from. Alongside those: the timeline policy drops from two hours to one, on a front-loaded ladder with a 90/120-minute tail so a throttled widget keeps reading honestly instead of freezing on its last entry; and the background task is now queued at launch as well, guarded by `pendingTaskRequests()` — `submit` replaces a pending request, so an unguarded call would push the window out forever for anyone who opens the app often. Finally, both widgets now always say how old their numbers are. The age existed but appeared only past `staleAfter`, and not at all on throughput — a widget silent about its age is asking to be read as live, and a line nobody has seen before reads as an error on the day it appears. --- apps/ios/Maple/App/MapleApp.swift | 18 +- apps/ios/Maple/Push/PushRegistrar.swift | 5 + apps/ios/Maple/Telemetry/Telemetry.swift | 10 + apps/ios/Maple/Widgets/WidgetPublisher.swift | 182 ++++++++-- .../Widgets/WidgetRefreshScheduler.swift | 14 + .../MapleWidgetData/WidgetRelativeTime.swift | 14 +- .../WidgetSnapshotContent.swift | 111 ++++++ .../WidgetTimelineSchedule.swift | 41 +++ .../WidgetSnapshotContentTests.swift | 329 ++++++++++++++++++ apps/ios/Widgets/IssuesWidget.swift | 17 +- apps/ios/Widgets/IssuesWidgetView.swift | 52 ++- apps/ios/Widgets/ThroughputWidget.swift | 12 +- apps/ios/Widgets/ThroughputWidgetView.swift | 26 +- 13 files changed, 762 insertions(+), 69 deletions(-) create mode 100644 apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSnapshotContent.swift create mode 100644 apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetTimelineSchedule.swift create mode 100644 apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetSnapshotContentTests.swift diff --git a/apps/ios/Maple/App/MapleApp.swift b/apps/ios/Maple/App/MapleApp.swift index 477dde03f..246813fb9 100644 --- a/apps/ios/Maple/App/MapleApp.swift +++ b/apps/ios/Maple/App/MapleApp.swift @@ -34,9 +34,18 @@ struct MapleApp: App { Telemetry.Launch.begin() if FixtureAPI.isEnabled { _clerk = State(initialValue: Clerk.configure(publishableKey: FixtureSession.publishableKey)) - let session = SessionController.fixture(api: FixtureAPI(), tokens: tokens) + let fixtureAPI = FixtureAPI() + let session = SessionController.fixture(api: fixtureAPI, tokens: tokens) _session = State(initialValue: session) opener.session = session + // `configure`, not `bootstrap`: fixture mode has no Clerk session and + // nothing in the App Group, so the headless path would correctly bail + // and screenshots would have no widgets to take. + WidgetPublisher.shared.configure( + api: fixtureAPI, + organizationId: FixtureSession.organizationId, + organizationName: nil + ) return } @@ -59,6 +68,13 @@ struct MapleApp: App { } let session = SessionController(api: api, tokens: tokens) _session = State(initialValue: session) + // After `Clerk.configure` above, which is what restores the session this + // reads. A launch into the background for a `BGAppRefreshTask` or a + // silent push builds no view tree, so this is the *only* place those + // wakes get a client and an organization; without it they woke up, found + // no context, and did nothing at all. `MainTabView` still calls + // `configure` with the verified membership list and overrides this. + WidgetPublisher.shared.bootstrap(api: api) // Assigned here rather than in `body`: a tap that launched the app can // reach the delegate before the first frame, and an opener with no // session parks every destination it is handed. diff --git a/apps/ios/Maple/Push/PushRegistrar.swift b/apps/ios/Maple/Push/PushRegistrar.swift index d3c820e7c..45155b8ed 100644 --- a/apps/ios/Maple/Push/PushRegistrar.swift +++ b/apps/ios/Maple/Push/PushRegistrar.swift @@ -233,6 +233,11 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent // Must happen before `didFinishLaunching` returns — registering a // BGTaskScheduler handler later throws. WidgetRefreshScheduler.register() + // The only other caller lives inside `MainTabView`, which exists only in + // the `.ready` phase — so a user sitting on the sign-in screen, or one + // who launches and immediately force-quits, never queued a background + // refresh at all. + Task { await WidgetRefreshScheduler.scheduleIfNeeded() } return true } diff --git a/apps/ios/Maple/Telemetry/Telemetry.swift b/apps/ios/Maple/Telemetry/Telemetry.swift index 98ce211e6..874465c56 100644 --- a/apps/ios/Maple/Telemetry/Telemetry.swift +++ b/apps/ios/Maple/Telemetry/Telemetry.swift @@ -74,6 +74,16 @@ enum Telemetry { static let pushAbandonReason = "maple.app.push.abandon_reason" static let pushOrganizationSwitched = "maple.app.push.org_switched" static let widgetSurface = "maple.app.widget.surface" + /// Whether this surface's fetch found anything the widget would draw + /// differently. False means the round deliberately spent no reload. + static let widgetChanged = "maple.app.widget.changed" + /// `WidgetCenter` reloads this round actually spent. iOS meters these, + /// so a widget that stopped updating is usually this number being too + /// high on the rounds where nothing happened. + static let widgetReloadCount = "maple.app.widget.reload_count" + /// How the publisher got its session: the view tree, or a headless + /// bootstrap in a background launch. + static let widgetContextSource = "maple.app.widget.context_source" static let liveActivityAction = "maple.app.live_activity.action" } diff --git a/apps/ios/Maple/Widgets/WidgetPublisher.swift b/apps/ios/Maple/Widgets/WidgetPublisher.swift index 2ad78cba6..538dd1fa6 100644 --- a/apps/ios/Maple/Widgets/WidgetPublisher.swift +++ b/apps/ios/Maple/Widgets/WidgetPublisher.swift @@ -1,3 +1,4 @@ +import ClerkKit import Foundation import Maple import MapleAPI @@ -20,6 +21,16 @@ import WidgetKit /// - an organization switch (same place, keyed on the org), /// - `BGAppRefreshTask` while the app is not running (`WidgetRefreshScheduler`), /// - a push arriving, silent or tapped (`AppDelegate`). +/// +/// The last two are the ones that keep a Home Screen current on a phone in a +/// pocket, and they run with no view tree — so the session they fetch with comes +/// from `bootstrap`, not `configure`. See there for what that fixes. +/// +/// Publishing is not the same as reloading. iOS meters `WidgetCenter` reloads, +/// and the widgets' own timelines are drawn from the same budget, so a round +/// only spends a reload on a kind whose numbers a reader could actually see +/// change — one per kind at most, however many organizations it covered. See +/// `WidgetReloadDecision`. @MainActor @Observable final class WidgetPublisher { @@ -59,8 +70,9 @@ final class WidgetPublisher { private let index: PublishedOrganizationIndex private var lastRefreshedAt: Date? - /// Set once the app knows who is signed in, so the background task — which - /// runs with no view tree — has something to fetch with. + /// Set once the app knows who is signed in — from the view tree by + /// `configure`, or at launch by `bootstrap`, which is the only one of the + /// two a background launch gets. private var context: Context? struct Context { @@ -88,6 +100,40 @@ final class WidgetPublisher { self.index = index } + /// The context a launch with **no view tree** can assemble for itself. + /// + /// This is the fix for the quietest bug the widgets had: `configure` is + /// reached only from `MainTabView`, so when iOS woke the app for a + /// `BGAppRefreshTask` or a silent push after it had been terminated, there + /// was no view tree, no context, and `refresh` returned at its first guard. + /// The two triggers that exist precisely to keep the Home Screen current + /// while the app is closed only ever worked when the app happened to still + /// be alive in memory — which is most of what "the widgets don't update" + /// was. + /// + /// Everything it needs is already on disk by the end of `MapleApp.init`: + /// Clerk restores its session from the keychain synchronously during + /// `configure(publishableKey:)`, and the App Group index carries the + /// organizations the app has published before. + /// + /// Sign-out needs no separate flag. `clear()` empties the index, so the + /// `published.contains` check below fails and a signed-out install cannot + /// resurrect a context on its next background wake. + func bootstrap(api: any MapleAPI) { + // The view tree always knows better — this only ever fills a gap. + guard context == nil else { return } + let published = index.load() + guard + let organizationId = Clerk.shared.session?.lastActiveOrganizationId, + let organization = published.first(where: { $0.id == organizationId }) + else { return } + // Memberships are the published set rather than Clerk's full list, which + // costs nothing: `organizationsToPublish` intersects with what is + // actually pinned anyway, and a background round's budget is one extra + // organization. + context = Context(api: api, active: organization, memberships: published) + } + /// Called whenever the signed-in organization, or the set the user belongs /// to, is known or changes. func configure( @@ -168,7 +214,7 @@ final class WidgetPublisher { Telemetry.Key.organizationId: .string(context.active.id), Telemetry.Key.widgetOrganizationCount: .int(Int64(organizations.count)), ] - ) { _ in + ) { span in let rounds = organizations.map { organization in PublishRound( organization: organization, @@ -182,23 +228,68 @@ final class WidgetPublisher { // inside `publish`, so this is `async let` rather than a task group — // which also keeps the whole round on one actor rather than making // `Context` `Sendable` for no gain. + var outcome = RoundOutcome() var index = rounds.startIndex while index < rounds.endIndex { let first = rounds[index] let second = rounds.indices.contains(index + 1) ? rounds[index + 1] : nil index += Self.maximumConcurrentOrganizations - async let firstDone: Void = self.publish(first) + async let firstDone = self.publish(first) if let second { - async let secondDone: Void = self.publish(second) - _ = await (firstDone, secondDone) + async let secondDone = self.publish(second) + let (left, right) = await (firstDone, secondDone) + outcome.merge(left) + outcome.merge(right) } else { - await firstDone + outcome.merge(await firstDone) } } + + // **One reload per kind, per round, and only when something a reader + // could see has changed.** + // + // This used to fire inside each surface's publish, so a three-organization + // round spent six — and `reloadTimelines(ofKind:)` rebuilds *every* + // instance of that kind, so publishing organization B was also + // dragging organization A's pinned widget through a rebuild with no + // new data for it. iOS meters reloads, that budget is shared with the + // timeline rebuilds that keep the widget alive while the app is + // closed, and spending it on identical redraws is what left the Home + // Screen looking frozen for the rest of the day. + var reloads = 0 + if outcome.issuesChanged { + WidgetCenter.shared.reloadTimelines(ofKind: IssuesWidgetKind.identifier) + reloads += 1 + } + if outcome.throughputChanged { + WidgetCenter.shared.reloadTimelines(ofKind: ThroughputWidgetKind.identifier) + reloads += 1 + } + span?.setAttribute(Telemetry.Key.widgetReloadCount, reloads) } } + /// What a whole round decided, folded across its organizations: if any one + /// of them has news, that kind reloads once for all of them. + private struct RoundOutcome { + var issuesChanged = false + var throughputChanged = false + + mutating func merge(_ other: (issues: PublishOutcome, throughput: PublishOutcome)) { + issuesChanged = issuesChanged || other.issues == .changed + throughputChanged = throughputChanged || other.throughput == .changed + } + } + + /// One surface's result. `unchanged` is a success that deliberately costs no + /// reload; `failed` is the fetch or the save going wrong. + private enum PublishOutcome: Sendable { + case failed + case unchanged + case changed + } + /// Everything one organization's round needs, and nothing that is not /// `Sendable` — `Context` holds the unscoped client and stays on the main /// actor. @@ -210,10 +301,10 @@ final class WidgetPublisher { /// One organization's round: both surfaces, then record it in the index the /// widget extension reads. - private func publish(_ round: PublishRound) async { - async let issues: Void = refreshIssues(round.organization, api: round.api) - async let throughput: Void = refreshThroughput(round.organization, api: round.api) - _ = await (issues, throughput) + private func publish(_ round: PublishRound) async -> (issues: PublishOutcome, throughput: PublishOutcome) { + async let issues = refreshIssues(round.organization, api: round.api) + async let throughput = refreshThroughput(round.organization, api: round.api) + let outcome = await (issues: issues, throughput: throughput) index.record( PublishedOrganization( @@ -223,6 +314,7 @@ final class WidgetPublisher { ), isActive: round.isActive ) + return outcome } /// Which organizations this round covers. @@ -283,13 +375,21 @@ final class WidgetPublisher { /// on a screen nobody asked to refresh — which before this made a widget /// stuck on yesterday's numbers completely undiagnosable. The span carries /// what the UI deliberately swallows. - private func snapshot(_ surface: String, _ body: @MainActor @Sendable @escaping () async -> Bool) async { + private func snapshot( + _ surface: String, + _ body: @MainActor @Sendable @escaping () async -> PublishOutcome + ) async -> PublishOutcome { await Telemetry.span( Telemetry.Name.widgetSnapshot, attributes: [Telemetry.Key.widgetSurface: .string(surface)] ) { span in - let published = await body() - span?.setStatus(published ? .ok : .error("snapshot not published")) + let outcome = await body() + span?.setStatus(outcome == .failed ? .error("snapshot not published") : .ok) + // The other half of the reload-budget story: a round of all-`false` + // here is the widget correctly staying put, not the publisher + // failing, and the two are indistinguishable without this. + span?.setAttribute(Telemetry.Key.widgetChanged, outcome == .changed) + return outcome } } @@ -314,11 +414,11 @@ final class WidgetPublisher { // MARK: Issues - private func refreshIssues(_ organization: PublishedOrganization, api: any MapleAPI) async { + private func refreshIssues(_ organization: PublishedOrganization, api: any MapleAPI) async -> PublishOutcome { await snapshot("issues") { await self.publishIssues(organization, api: api) } } - private func publishIssues(_ organization: PublishedOrganization, api: any MapleAPI) async -> Bool { + private func publishIssues(_ organization: PublishedOrganization, api: any MapleAPI) async -> PublishOutcome { guard let page = try? await api.issues( query: IssueQuery(actionableOnly: true, sort: .severity), @@ -326,31 +426,38 @@ final class WidgetPublisher { limit: Self.issueFetchLimit, cursor: nil ) - else { return false } + else { return .failed } + let now = Date() let snapshot = IssuesSnapshot.make( organizationId: organization.id, organizationName: organization.name, - generatedAt: Date(), + generatedAt: now, issues: page.items.map(WidgetIssue.init(issue:)), hasMore: page.hasMore ) - guard WidgetSnapshotStore.issues(organizationId: organization.id).save(snapshot) - else { return false } - // Reload rather than wait for the next timeline entry: the whole point - // of publishing from the app is that the Home Screen updates the moment - // the app learns something. - WidgetCenter.shared.reloadTimelines(ofKind: IssuesWidgetKind.identifier) - return true + let store = WidgetSnapshotStore.issues(organizationId: organization.id) + // Read before writing: the reload decision is "does this differ from + // what is on screen", and after the save there is nothing to compare to. + let stored = store.load() + // Saved unconditionally even when nothing changed, so `generatedAt` + // advances and the widget's footer is honest the next time it is built + // for any reason. + guard store.save(snapshot) else { return .failed } + return WidgetReloadDecision.shouldReload( + stored: stored, + incoming: snapshot, + storedIsStale: stored?.isStale(at: now) ?? false + ) ? .changed : .unchanged } // MARK: Throughput - private func refreshThroughput(_ organization: PublishedOrganization, api: any MapleAPI) async { + private func refreshThroughput(_ organization: PublishedOrganization, api: any MapleAPI) async -> PublishOutcome { await snapshot("throughput") { await self.publishThroughput(organization, api: api) } } - private func publishThroughput(_ organization: PublishedOrganization, api: any MapleAPI) async -> Bool { + private func publishThroughput(_ organization: PublishedOrganization, api: any MapleAPI) async -> PublishOutcome { let window = Self.throughputWindow.resolve() // Three requests, not one per service: `group_by: service` returns @@ -370,7 +477,7 @@ final class WidgetPublisher { TraceTimeseriesRequest(aggregation: .count, window: window) ) - guard let services = try? await servicesTask.items else { return false } + guard let services = try? await servicesTask.items else { return .failed } let grouped = try? await groupedTask let total = try? await totalTask @@ -392,18 +499,25 @@ final class WidgetPublisher { overall.points = Self.perSecond(total.values, bucketSeconds: total.bucketSeconds) } + let now = Date() let snapshot = ThroughputSnapshot.make( organizationId: organization.id, - generatedAt: Date(), + generatedAt: now, windowMinutes: Int(Self.throughputWindow.duration / 60), services: rows, overall: overall ) - guard - WidgetSnapshotStore.throughput(organizationId: organization.id).save(snapshot) - else { return false } - WidgetCenter.shared.reloadTimelines(ofKind: ThroughputWidgetKind.identifier) - return true + let store = WidgetSnapshotStore.throughput(organizationId: organization.id) + let stored = store.load() + guard store.save(snapshot) else { return .failed } + // Throughput is the surface where suppression earns the most: its floats + // differ on every fetch, but `contentFingerprint` compares the rendered + // strings, so a rate that still reads "12.5/s" costs nothing. + return WidgetReloadDecision.shouldReload( + stored: stored, + incoming: snapshot, + storedIsStale: stored?.isStale(at: now) ?? false + ) ? .changed : .unchanged } /// Spans per bucket → spans per second, so the sparkline carries the same diff --git a/apps/ios/Maple/Widgets/WidgetRefreshScheduler.swift b/apps/ios/Maple/Widgets/WidgetRefreshScheduler.swift index 22efdf7fb..6213f40f0 100644 --- a/apps/ios/Maple/Widgets/WidgetRefreshScheduler.swift +++ b/apps/ios/Maple/Widgets/WidgetRefreshScheduler.swift @@ -53,6 +53,20 @@ enum WidgetRefreshScheduler { } } + /// Queue one only if nothing is queued already. + /// + /// For the launch path, where `schedule()` alone would be actively harmful: + /// `submit` *replaces* the pending request for an identifier, pushing + /// `earliestBeginDate` out another fifteen minutes. Someone who opens the + /// app every ten would reset the timer forever and the task would never once + /// fire. The `.background` transition keeps calling `schedule()` directly — + /// there, moving the window is the intent. + static func scheduleIfNeeded() async { + let pending = await BGTaskScheduler.shared.pendingTaskRequests() + guard !pending.contains(where: { $0.identifier == taskIdentifier }) else { return } + schedule() + } + private static func handle(_ task: BGAppRefreshTask) { // Chain the next one first: if the refresh below hangs and the system // kills us, a request is already queued rather than the chain quietly diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetRelativeTime.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetRelativeTime.swift index daa0c4a10..34e59975b 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetRelativeTime.swift +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetRelativeTime.swift @@ -24,8 +24,8 @@ public enum WidgetTime { ) } - /// "as of 12m ago" for the footer. Spelled out rather than terse, because - /// here the age is the subject rather than a column. + /// "12m ago" for the footer. Spelled out rather than terse, because here the + /// age is the subject rather than a column. public static func age(_ interval: TimeInterval) -> String { if interval < 60 { return "just now" } if interval < 3600 { return "\(Int(interval / 60))m ago" } @@ -33,6 +33,16 @@ public enum WidgetTime { return "\(Int(interval / 86_400))d ago" } + /// The footer copy, in one place so both widgets say it identically: + /// "updated 12m ago", "updated just now". + /// + /// Always shown, not only when stale. A widget that states its age is a + /// widget you can trust at a glance; one that goes quiet about it is asking + /// to be believed. + public static func updated(_ interval: TimeInterval) -> String { + "updated \(age(interval))" + } + /// `Format.count`'s K/M/B abbreviation, for the per-row event count. public static func count(_ value: Double) -> String { guard value.isFinite else { return "—" } diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSnapshotContent.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSnapshotContent.swift new file mode 100644 index 000000000..910ec302a --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSnapshotContent.swift @@ -0,0 +1,111 @@ +import Foundation + +/// "Would the widget draw the same thing?", for deciding whether a refresh has +/// earned a `WidgetCenter` reload. +/// +/// iOS meters widget reloads, and the app's own `reloadTimelines` calls come out +/// of the same budget as the timeline rebuilds that keep the widget alive when +/// the app is closed. So a round that fetched and found nothing new must not +/// spend one: every wasted reload is one that is not available later for the +/// round that would have shown a new critical. +/// +/// **Equality is the wrong test.** Both snapshots carry fields that move on +/// every single fetch without changing a glyph on screen — `generatedAt` +/// obviously, but also `WidgetIssue.lastSeenAt` (seconds), `occurrenceCount` +/// (still renders "41.2K"), and `ServiceThroughput.points` (a sliding window, +/// so every bucket shifts). `!=` would report "changed" every round and buy +/// nothing at all. +/// +/// So conformers project themselves through the very same `WidgetFormat` / +/// `WidgetTime` functions the views call. "Changed" then means literally "a +/// reader could see a difference", and the rule cannot drift away from what the +/// views do without a test noticing. +public protocol WidgetSnapshotContent { + /// Everything this snapshot can put on screen, and nothing else. + /// + /// A `String` rather than a hash: `hashValue` is seeded per process, so it + /// is not comparable across the app-and-extension boundary or across + /// launches — and this one can be read in a debugger. + var contentFingerprint: String { get } +} + +/// Whether a freshly fetched snapshot has earned a reload. +/// +/// Deliberately date-free: staleness is passed in rather than computed, so the +/// whole rule is a pure function of two snapshots and a bool. +public enum WidgetReloadDecision { + public static func shouldReload( + stored: (any WidgetSnapshotContent)?, + incoming: any WidgetSnapshotContent, + storedIsStale: Bool + ) -> Bool { + // Nothing on screen yet, or nothing readable: anything is an improvement. + guard let stored else { return true } + if stored.contentFingerprint != incoming.contentFingerprint { return true } + // Identical content, but what is on screen is dimmed and captioned + // "updated 2h ago" — because suppressing a reload means the widget keeps + // rendering the *old* `generatedAt` until its next timeline build. The + // numbers have just been confirmed current, so say so. Bounded to one + // reload per `staleAfter` per surface. + return storedIsStale + } +} + +extension IssuesSnapshot: WidgetSnapshotContent { + public var contentFingerprint: String { + var parts: [String] = [ + organizationId, + organizationName ?? "", + // The headline, as rendered: the count and whether it reads "20+". + "\(openCount)\(isCapped ? "+" : "")", + "\(criticalCount)", + "\(highCount)", + ] + for issue in issues { + parts.append( + [ + issue.id, + issue.title, + issue.subtitle ?? "", + issue.serviceName, + issue.severity?.rawValue ?? "", + // The abbreviated form the row shows, so 41 210 → 41 240 + // events is the same "41.2K" and costs no reload. + WidgetTime.count(issue.occurrenceCount), + // The relative label's own resolution below an hour. A row + // that still says "12m" is not worth a redraw. + "\(Int(issue.lastSeenAt.timeIntervalSince1970 / 60))", + issue.isRegressed ? "regressed" : "", + issue.hasOpenIncident ? "paging" : "", + ].joined(separator: "\u{1f}") + ) + } + return parts.joined(separator: "\n") + } +} + +extension ThroughputSnapshot: WidgetSnapshotContent { + public var contentFingerprint: String { + ([organizationId, "\(windowMinutes)"] + ([overall] + services).map(\.renderedFields)) + .joined(separator: "\n") + } +} + +extension ServiceThroughput { + /// Every string this row puts on screen. + /// + /// `points` is excluded on purpose: an hour-long series scrolling by one + /// bucket is invisible, and including it would make suppression a no-op on + /// any org with traffic. `trend` *is* included, and it is derived from + /// `points` — so a change in the shape that actually means something still + /// earns a reload. + var renderedFields: String { + [ + displayName, + WidgetFormat.rate(throughputPerSecond), + WidgetFormat.errorRate(errorRate), + WidgetFormat.latency(p95LatencyMs), + WidgetFormat.trend(trend) ?? "", + ].joined(separator: "\u{1f}") + } +} diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetTimelineSchedule.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetTimelineSchedule.swift new file mode 100644 index 000000000..5368beb9b --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetTimelineSchedule.swift @@ -0,0 +1,41 @@ +import Foundation + +/// When a widget's timeline should render, and when WidgetKit should come back +/// for a new one. +/// +/// Both Home Screen widgets read their snapshot **once** per timeline build and +/// then reuse it for every entry: the data does not change between entries, +/// only its age does. So the entries exist purely to keep the relative labels +/// honest — "12m", "Updated 4m ago" — and they are free, because WidgetKit +/// pre-renders them from one build. +/// +/// The *policy* is the part that costs. iOS meters how often it will rebuild a +/// timeline, and the same budget pays for the app's own `reloadTimelines` +/// calls — which are what actually make the widget current when something +/// happens. So this asks for one rebuild an hour and spends the rest of the +/// budget on reloads; see `WidgetPublisher`. +public enum WidgetTimelineSchedule { + /// Minutes from the build, front-loaded: a snapshot's age reads "1m", "2m", + /// "5m" in its first quarter hour and then changes far more slowly, so + /// evenly spaced entries would be wrong exactly where the reader is most + /// likely to be looking. + /// + /// The tail past `refreshAfter` is the part that is easy to leave out and + /// shouldn't be: if iOS throttles the rebuild, these are what let the widget + /// keep saying an honest "90m" instead of insisting forever that it is an + /// hour old. + public static let offsetMinutes: [Int] = [0, 1, 2, 5, 10, 15, 20, 30, 45, 60, 90, 120] + + /// One timeline request an hour. iOS meters those against the same budget as + /// the app's `reloadTimelines` calls, so this is the half of the budget the + /// widget spends on its own; see `WidgetPublisher` for the other half. + public static let refreshAfter: TimeInterval = 60 * 60 + + public static func entryDates(from date: Date) -> [Date] { + offsetMinutes.map { date.addingTimeInterval(Double($0) * 60) } + } + + public static func refreshDate(from date: Date) -> Date { + date.addingTimeInterval(refreshAfter) + } +} diff --git a/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetSnapshotContentTests.swift b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetSnapshotContentTests.swift new file mode 100644 index 000000000..e290ebcff --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetSnapshotContentTests.swift @@ -0,0 +1,329 @@ +import Foundation +import Testing + +@testable import MapleWidgetData + +private let now = Date(timeIntervalSince1970: 1_800_000_000) + +private func issue( + id: String = "iss_1", + title: String = "TypeError", + subtitle: String? = "undefined is not a function", + service: String = "api", + severity: WidgetIssueSeverity? = .critical, + count: Double = 41_210, + lastSeen: Date = now.addingTimeInterval(-600), + regressed: Bool = false, + paging: Bool = false +) -> WidgetIssue { + WidgetIssue( + id: id, + title: title, + subtitle: subtitle, + serviceName: service, + severity: severity, + occurrenceCount: count, + lastSeenAt: lastSeen, + isRegressed: regressed, + hasOpenIncident: paging + ) +} + +private func issues(_ rows: [WidgetIssue], at generatedAt: Date = now) -> IssuesSnapshot { + IssuesSnapshot.make( + organizationId: "org_1", + organizationName: "Maple", + generatedAt: generatedAt, + issues: rows + ) +} + +private func service( + _ name: String?, + throughput: Double, + errorRate: Double = 0, + p95: Double = 100, + points: [Double] = [1, 2, 3, 4] +) -> ServiceThroughput { + ServiceThroughput( + name: name, + throughputPerSecond: throughput, + errorRate: errorRate, + p95LatencyMs: p95, + points: points + ) +} + +private func throughput(_ rows: [ServiceThroughput], at generatedAt: Date = now) -> ThroughputSnapshot { + ThroughputSnapshot.make( + organizationId: "org_1", + generatedAt: generatedAt, + windowMinutes: 60, + services: rows + ) +} + +@Suite("Issues content fingerprint") +struct IssuesContentFingerprintTests { + @Test("Ignores when the snapshot was fetched") + func ignoresGeneratedAt() { + #expect( + issues([issue()]).contentFingerprint + == issues([issue()], at: now.addingTimeInterval(3600)).contentFingerprint + ) + } + + /// The reason plain `Equatable` is unusable here: on any live organization + /// `lastSeenAt` moves every fetch, and a row still reading "10m" is not a + /// reason to spend a reload. + @Test("Ignores lastSeenAt moving inside the same rendered minute") + func ignoresSubMinuteRecency() { + let base = issue(lastSeen: Date(timeIntervalSince1970: 1_799_999_400)) + let moved = issue(lastSeen: Date(timeIntervalSince1970: 1_799_999_440)) + #expect(issues([base]).contentFingerprint == issues([moved]).contentFingerprint) + } + + @Test("Notices lastSeenAt crossing a minute") + func noticesMinuteBoundary() { + let base = issue(lastSeen: Date(timeIntervalSince1970: 1_799_999_400)) + let moved = issue(lastSeen: Date(timeIntervalSince1970: 1_799_999_460)) + #expect(issues([base]).contentFingerprint != issues([moved]).contentFingerprint) + } + + @Test("Ignores an occurrence count that still abbreviates the same") + func ignoresSubDisplayCount() { + #expect( + issues([issue(count: 41_210)]).contentFingerprint + == issues([issue(count: 41_240)]).contentFingerprint + ) + } + + @Test("Notices an occurrence count that renders differently") + func noticesRenderedCount() { + #expect( + issues([issue(count: 41_210)]).contentFingerprint + != issues([issue(count: 41_900)]).contentFingerprint + ) + } + + @Test( + "Notices anything the widget draws", + arguments: [ + issue(title: "RangeError"), + issue(subtitle: nil), + issue(service: "web"), + issue(severity: .high), + issue(severity: nil), + issue(regressed: true), + issue(paging: true), + issue(id: "iss_2"), + ] + ) + func noticesRenderedFields(_ changed: WidgetIssue) { + #expect(issues([issue()]).contentFingerprint != issues([changed]).contentFingerprint) + } + + @Test("Notices the headline count and the 20+ cap") + func noticesHeadline() { + let one = issues([issue()]) + let two = issues([issue(), issue(id: "iss_2", severity: .high)]) + #expect(one.contentFingerprint != two.contentFingerprint) + + var capped = one + capped.isCapped = true + #expect(one.contentFingerprint != capped.contentFingerprint) + } + + @Test("Notices the organization name shown in the header") + func noticesOrganizationName() { + var renamed = issues([issue()]) + renamed.organizationName = "Maple Inc" + #expect(issues([issue()]).contentFingerprint != renamed.contentFingerprint) + } + + /// `make`'s ordering is load-bearing for suppression: two builds of the same + /// unordered input must fingerprint identically, or every round looks changed. + @Test("Is stable across the input order") + func stableAcrossInputOrder() { + let rows = [ + issue(id: "a", severity: .high), + issue(id: "b", severity: .critical), + issue(id: "c", severity: .low), + ] + #expect(issues(rows).contentFingerprint == issues(rows.reversed()).contentFingerprint) + } +} + +@Suite("Throughput content fingerprint") +struct ThroughputContentFingerprintTests { + /// The whole reason this surface needs a rendered projection: `points` is a + /// sliding window, so every bucket shifts on every fetch. + /// Steady traffic, one bucket on: every value differs, the picture does not. + /// A scroll big enough to move the rendered trend is a different matter and + /// does earn a reload — see `noticesTrend`. + @Test("Ignores the sparkline scrolling") + func ignoresSparklineScroll() { + let before = throughput([service("api", throughput: 12.5, points: [10, 11, 10, 11])]) + let after = throughput([service("api", throughput: 12.5, points: [11, 10, 11, 10])]) + #expect(before.contentFingerprint == after.contentFingerprint) + } + + @Test("Ignores a rate that still renders the same") + func ignoresSubDisplayRate() { + #expect( + throughput([service("api", throughput: 12.51)]).contentFingerprint + == throughput([service("api", throughput: 12.54)]).contentFingerprint + ) + } + + @Test("Notices a rate crossing a rounding boundary") + func noticesRenderedRate() { + #expect( + throughput([service("api", throughput: 12.5)]).contentFingerprint + != throughput([service("api", throughput: 13.1)]).contentFingerprint + ) + } + + @Test("Ignores an error rate that still renders the same") + func ignoresSubDisplayErrorRate() { + #expect( + throughput([service("api", throughput: 10, errorRate: 0.010_41)]).contentFingerprint + == throughput([service("api", throughput: 10, errorRate: 0.010_44)]).contentFingerprint + ) + } + + @Test("Notices an error rate the reader would see change") + func noticesRenderedErrorRate() { + #expect( + throughput([service("api", throughput: 10, errorRate: 0.01)]).contentFingerprint + != throughput([service("api", throughput: 10, errorRate: 0.09)]).contentFingerprint + ) + } + + @Test("Notices p95 the reader would see change") + func noticesLatency() { + #expect( + throughput([service("api", throughput: 10, p95: 100)]).contentFingerprint + != throughput([service("api", throughput: 10, p95: 480)]).contentFingerprint + ) + } + + /// Trend is derived from `points`, so a change of shape that actually means + /// something still earns a reload even though the buckets are excluded. + @Test("Notices the trend flipping") + func noticesTrend() { + let rising = throughput([service("api", throughput: 10, points: [1, 1, 8, 8])]) + let falling = throughput([service("api", throughput: 10, points: [8, 8, 1, 1])]) + #expect(rising.contentFingerprint != falling.contentFingerprint) + } + + @Test("Notices a service appearing or leaving the list") + func noticesServiceSet() { + let one = throughput([service("api", throughput: 10)]) + let two = throughput([service("api", throughput: 10), service("web", throughput: 4)]) + #expect(one.contentFingerprint != two.contentFingerprint) + } + + @Test("Notices the window changing") + func noticesWindow() { + var hour = throughput([service("api", throughput: 10)]) + hour.windowMinutes = 15 + #expect(throughput([service("api", throughput: 10)]).contentFingerprint != hour.contentFingerprint) + } +} + +@Suite("Reload decision") +struct WidgetReloadDecisionTests { + @Test("Reloads when nothing is on screen yet") + func firstPublish() { + #expect( + WidgetReloadDecision.shouldReload( + stored: nil, + incoming: issues([issue()]), + storedIsStale: false + ) + ) + } + + /// The point of the whole mechanism: an unchanged round costs no reload. + @Test("Stays put when nothing a reader could see changed") + func suppressesUnchanged() { + #expect( + !WidgetReloadDecision.shouldReload( + stored: issues([issue()]), + incoming: issues([issue()], at: now.addingTimeInterval(120)), + storedIsStale: false + ) + ) + } + + /// Suppressing means the widget keeps rendering the *old* `generatedAt`, so + /// identical-but-stale still reloads — otherwise it stays dimmed and + /// captioned "updated 2h ago" while the numbers are actually current. + @Test("Reloads identical content when what is on screen had gone stale") + func reloadsToUndim() { + #expect( + WidgetReloadDecision.shouldReload( + stored: issues([issue()]), + incoming: issues([issue()], at: now.addingTimeInterval(7200)), + storedIsStale: true + ) + ) + } + + @Test("Reloads when the content changed") + func reloadsChanged() { + #expect( + WidgetReloadDecision.shouldReload( + stored: issues([issue()]), + incoming: issues([issue(severity: .low)]), + storedIsStale: false + ) + ) + } +} + +@Suite("Timeline schedule") +struct WidgetTimelineScheduleTests { + @Test("Offsets start at zero and only move forward") + func monotonic() { + let offsets = WidgetTimelineSchedule.offsetMinutes + #expect(offsets.first == 0) + #expect(zip(offsets, offsets.dropFirst()).allSatisfy { $0 < $1 }) + } + + @Test("Entries are the offsets, applied to the build time") + func entryDates() { + let dates = WidgetTimelineSchedule.entryDates(from: now) + #expect(dates.count == WidgetTimelineSchedule.offsetMinutes.count) + for (date, minutes) in zip(dates, WidgetTimelineSchedule.offsetMinutes) { + #expect(date == now.addingTimeInterval(Double(minutes) * 60)) + } + } + + /// Entries past the refresh date are what let a throttled widget keep + /// saying an honest "90m" instead of freezing on the last one it has. + @Test("Some entries outlive the refresh request") + func tailOutlivesRefresh() { + let refresh = WidgetTimelineSchedule.refreshDate(from: now) + #expect(refresh == now.addingTimeInterval(WidgetTimelineSchedule.refreshAfter)) + #expect(WidgetTimelineSchedule.entryDates(from: now).contains { $0 > refresh }) + } +} + +@Suite("Updated footer copy") +struct WidgetUpdatedCopyTests { + @Test( + "Reads as a sentence at every scale", + arguments: [ + (TimeInterval(0), "updated just now"), + (TimeInterval(59), "updated just now"), + (TimeInterval(12 * 60), "updated 12m ago"), + (TimeInterval(3 * 3600), "updated 3h ago"), + (TimeInterval(2 * 86_400), "updated 2d ago"), + ] + ) + func copy(_ interval: TimeInterval, _ expected: String) { + #expect(WidgetTime.updated(interval) == expected) + } +} diff --git a/apps/ios/Widgets/IssuesWidget.swift b/apps/ios/Widgets/IssuesWidget.swift index 6d911fdcc..d7ce69652 100644 --- a/apps/ios/Widgets/IssuesWidget.swift +++ b/apps/ios/Widgets/IssuesWidget.swift @@ -131,22 +131,21 @@ struct IssuesProvider: AppIntentTimelineProvider { WidgetSnapshotStore.legacyIssues.load() } - /// Entries every quarter hour for the next two, from a single read. + /// One read, rendered at every point on `WidgetTimelineSchedule`'s ladder. /// /// The data does not change between them — only its age does, and the row - /// times ("2m", "3h") are relative, so without these the widget would still - /// claim "2m" an hour later. WidgetKit is told to come back after the last - /// one; the app's own `reloadTimelines` is what actually keeps it current - /// when something happens. + /// times ("2m", "3h") and the footer are relative, so without these the + /// widget would still claim "2m" an hour later. WidgetKit is told to come + /// back after the last one; the app's own `reloadTimelines` is what actually + /// keeps it current when something happens. func timeline(for configuration: SelectOrganizationIntent, in context: Context) async -> Timeline { let now = Date() - let step: TimeInterval = 15 * 60 let base = makeEntry(for: configuration, at: now) - let entries = (0..<8).map { offset -> IssuesEntry in + let entries = WidgetTimelineSchedule.entryDates(from: now).map { date -> IssuesEntry in var entry = base - entry.date = now.addingTimeInterval(Double(offset) * step) + entry.date = date return entry } - return Timeline(entries: entries, policy: .after(now.addingTimeInterval(8 * step))) + return Timeline(entries: entries, policy: .after(WidgetTimelineSchedule.refreshDate(from: now))) } } diff --git a/apps/ios/Widgets/IssuesWidgetView.swift b/apps/ios/Widgets/IssuesWidgetView.swift index 5b9693ae1..1210e7386 100644 --- a/apps/ios/Widgets/IssuesWidgetView.swift +++ b/apps/ios/Widgets/IssuesWidgetView.swift @@ -19,7 +19,7 @@ struct IssuesWidgetView: View { case .accessoryCircular: CircularView(entry: entry) case .accessoryRectangular: RectangularView(entry: entry) case .systemSmall: SmallView(entry: entry) - default: ListView(entry: entry, rowLimit: family == .systemLarge ? 6 : 3) + default: ListView(entry: entry, isLarge: family == .systemLarge) } } } @@ -44,7 +44,7 @@ private struct SmallView: View { IssueRowView(issue: top, now: entry.date, showsCount: false, showsTrailingTime: false) } - StalenessFooter(snapshot: snapshot, now: entry.date) + UpdatedFooter(snapshot: snapshot, now: entry.date) } } .widgetURL(IssuesWidgetKind.issuesListURL(organizationId: entry.organizationId)) @@ -54,7 +54,9 @@ private struct SmallView: View { /// Medium and large: the same list, cut to what fits. private struct ListView: View { let entry: IssuesEntry - let rowLimit: Int + let isLarge: Bool + + private var rowLimit: Int { isLarge ? 6 : 3 } var body: some View { WidgetFrame(entry: entry) { snapshot in @@ -67,10 +69,12 @@ private struct ListView: View { // Everything secondary on one line: at 155pt tall the medium // family has room for a header, three rows, and nothing else — - // a separate footer row got clipped. + // a separate footer row got clipped. So medium is the one family + // where the age rides this line instead of a footer, and large — + // which has the room — takes the footer below. SeverityLine( snapshot: snapshot, - now: entry.date, + now: isLarge ? nil : entry.date, extra: snapshot.openCount > rowLimit ? "+\(snapshot.openCount - rowLimit) more" : nil ) .padding(.bottom, 6) @@ -89,6 +93,10 @@ private struct ListView: View { } Spacer(minLength: 0) + + if isLarge { + UpdatedFooter(snapshot: snapshot, now: entry.date) + } } } .widgetURL(IssuesWidgetKind.issuesListURL(organizationId: entry.organizationId)) @@ -325,6 +333,11 @@ private struct SeverityLine: View { .tabularNumbers() .foregroundStyle(Token.mutedForeground) .lineLimit(1) + // The age is the last segment, so plain truncation would drop + // exactly the thing this line was widened to carry. A worst-case + // medium ("3 critical · 2 high · +5 more · updated 12m ago") fits at + // full size; this is the margin for a wider accessibility face. + .minimumScaleFactor(0.85) } private var summary: String { @@ -333,7 +346,9 @@ private struct SeverityLine: View { if snapshot.highCount > 0 { parts.append("\(snapshot.highCount) high") } if parts.isEmpty { parts.append("needs attention") } if let extra { parts.append(extra) } - if let now, snapshot.isStale(at: now) { parts.append("as of \(WidgetTime.age(snapshot.age(at: now)))") } + // Only the families with no room for `UpdatedFooter` pass `now` — see + // `ListView`. Exactly one age per family, never two. + if let now { parts.append(WidgetTime.updated(snapshot.age(at: now))) } return parts.joined(separator: " · ") } } @@ -432,7 +447,7 @@ private struct EmptyStateView: View { .font(Typo.tiny) .foregroundStyle(Token.mutedForeground) Spacer(minLength: 0) - StalenessFooter(snapshot: snapshot, now: now) + UpdatedFooter(snapshot: snapshot, now: now) } } } @@ -461,19 +476,24 @@ private struct DisconnectedView: View { } } -/// Shown only once the data is old enough to mislead. A timestamp on fresh -/// data is noise; on stale data it is the most important thing on the widget. -private struct StalenessFooter: View { +/// How old the numbers are, always. +/// +/// It used to appear only past `staleAfter`, on the theory that a timestamp on +/// fresh data is noise. That was wrong in the one way that matters: a widget +/// silent about its age is asking to be taken as live, and a reader who has +/// never seen the line has no reason to expect it — so on the day it does +/// appear, it reads as a new kind of error rather than as an age. Stated every +/// time, it is a fact you learn to glance at, and the dimming past `staleAfter` +/// is what escalates it. +private struct UpdatedFooter: View { let snapshot: IssuesSnapshot let now: Date var body: some View { - if snapshot.isStale(at: now) { - Text("as of \(WidgetTime.age(snapshot.age(at: now)))") - .font(Typo.micro) - .tabularNumbers() - .foregroundStyle(Token.mutedForeground) - } + Text(WidgetTime.updated(snapshot.age(at: now))) + .font(Typo.micro) + .tabularNumbers() + .foregroundStyle(Token.mutedForeground) } } diff --git a/apps/ios/Widgets/ThroughputWidget.swift b/apps/ios/Widgets/ThroughputWidget.swift index c57518adb..fa431bd74 100644 --- a/apps/ios/Widgets/ThroughputWidget.swift +++ b/apps/ios/Widgets/ThroughputWidget.swift @@ -109,17 +109,17 @@ struct ThroughputProvider: AppIntentTimelineProvider { } /// One read, several entries — the numbers do not change between them, - /// only how old they are. The app's `reloadTimelines` is what actually - /// keeps this current; the entries are the floor. + /// only how old they are. Same ladder as the issues widget, so the two + /// widgets' footers never disagree about the time; see + /// `WidgetTimelineSchedule`. func timeline(for configuration: SelectServiceIntent, in context: Context) async -> Timeline { let now = Date() - let step: TimeInterval = 15 * 60 let base = makeEntry(for: configuration, at: now) - let entries = (0..<8).map { offset -> ThroughputEntry in + let entries = WidgetTimelineSchedule.entryDates(from: now).map { date -> ThroughputEntry in var entry = base - entry.date = now.addingTimeInterval(Double(offset) * step) + entry.date = date return entry } - return Timeline(entries: entries, policy: .after(now.addingTimeInterval(8 * step))) + return Timeline(entries: entries, policy: .after(WidgetTimelineSchedule.refreshDate(from: now))) } } diff --git a/apps/ios/Widgets/ThroughputWidgetView.swift b/apps/ios/Widgets/ThroughputWidgetView.swift index ba986332e..8a76fddbc 100644 --- a/apps/ios/Widgets/ThroughputWidgetView.swift +++ b/apps/ios/Widgets/ThroughputWidgetView.swift @@ -51,6 +51,9 @@ private struct SmallThroughputView: View { QualityLine(service: service) .padding(.top, 6) + + ThroughputUpdatedFooter(snapshot: snapshot, now: entry.date) + .padding(.top, 4) } } } @@ -68,6 +71,8 @@ private struct MediumThroughputView: View { TrendLine(service: service, snapshot: snapshot) Spacer(minLength: 4) QualityLine(service: service, isStacked: true) + ThroughputUpdatedFooter(snapshot: snapshot, now: entry.date) + .padding(.top, 4) } .frame(maxWidth: 150, alignment: .leading) @@ -112,8 +117,13 @@ private struct LargeThroughputView: View { Spacer(minLength: 0) - Text(windowLabel(snapshot)) + // The window and the age are different facts — "last hour" is + // what the numbers measure, "updated 4m ago" is when we last + // asked. Same line because large already ends here, but joined + // so neither can be read as the other. + Text("\(windowLabel(snapshot)) · \(WidgetTime.updated(snapshot.age(at: entry.date)))") .font(Typo.micro) + .tabularNumbers() .foregroundStyle(Token.mutedForeground) } } @@ -290,6 +300,20 @@ private struct MissingOrganizationView: View { } } +/// How old the numbers are, always — the twin of `UpdatedFooter` on the issues +/// widget, so the two read as one system on the same Home Screen. +private struct ThroughputUpdatedFooter: View { + let snapshot: ThroughputSnapshot + let now: Date + + var body: some View { + Text(WidgetTime.updated(snapshot.age(at: now))) + .font(Typo.micro) + .tabularNumbers() + .foregroundStyle(Token.mutedForeground) + } +} + private struct RateLine: View { let service: ServiceThroughput