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