summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-12 00:15:13 -0500
committerChristian Cleberg <[email protected]>2026-04-12 00:15:13 -0500
commitcb1ea5ea4f163d87053285d3fb7999b12a3558b9 (patch)
tree84b562d12e642ff494194387b1b4e1d15b065eb0
parent1c5a417277d986ee6fb7dc9dff0332338bdf6f3e (diff)
downloadhutch-cb1ea5ea4f163d87053285d3fb7999b12a3558b9.tar.gz
hutch-cb1ea5ea4f163d87053285d3fb7999b12a3558b9.tar.bz2
hutch-cb1ea5ea4f163d87053285d3fb7999b12a3558b9.zip
harden system status and app reliabilityv2.13.1
-rw-r--r--Hutch.xcodeproj/project.pbxproj16
-rw-r--r--Hutch/App/AppStorageKeys.swift2
-rw-r--r--Hutch/Networking/SystemStatusCacheStore.swift59
-rw-r--r--Hutch/Networking/SystemStatusRepository.swift172
-rw-r--r--Hutch/Networking/SystemStatusService.swift118
-rw-r--r--Hutch/Views/Home/HomeView.swift48
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift28
-rw-r--r--Hutch/Views/More/MoreView.swift27
-rw-r--r--Hutch/Views/More/MoreViewModel.swift39
-rw-r--r--Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift119
-rw-r--r--Hutch/Views/SystemStatus/SystemStatusView.swift8
-rw-r--r--Hutch/Views/SystemStatus/SystemStatusViewModel.swift34
-rw-r--r--HutchTests/AppStateTests.swift11
-rw-r--r--HutchTests/SystemStatusRepositoryTests.swift147
-rw-r--r--HutchTests/SystemStatusServiceTests.swift63
15 files changed, 790 insertions, 101 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 2846239..ed3e455 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 29;
+ CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.13.0;
+ MARKETING_VERSION = 2.13.1;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 29;
+ CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.13.0;
+ MARKETING_VERSION = 2.13.1;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 29;
+ CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.13.0;
+ MARKETING_VERSION = 2.13.1;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 29;
+ CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.13.0;
+ MARKETING_VERSION = 2.13.1;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift
index dcf38ba..c1b39c4 100644
--- a/Hutch/App/AppStorageKeys.swift
+++ b/Hutch/App/AppStorageKeys.swift
@@ -6,4 +6,6 @@ enum AppStorageKeys {
static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
static let lookupHistory = "lookupHistory"
static let hutchStatsBaseURL = "hutchStatsBaseURL"
+ static let systemStatusSnapshotCache = "systemStatusSnapshotCache"
+ static let systemStatusIncidentCache = "systemStatusIncidentCache"
}
diff --git a/Hutch/Networking/SystemStatusCacheStore.swift b/Hutch/Networking/SystemStatusCacheStore.swift
new file mode 100644
index 0000000..63c64b9
--- /dev/null
+++ b/Hutch/Networking/SystemStatusCacheStore.swift
@@ -0,0 +1,59 @@
+import Foundation
+
+actor SystemStatusCacheStore {
+ private let snapshotCacheKey = "systemStatusSnapshotCache"
+ private let incidentCacheKey = "systemStatusIncidentCache"
+ private let defaults: UserDefaults
+
+ init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ }
+
+ func loadSnapshotHTML() -> (html: String, timestamp: Date)? {
+ guard let html = defaults.string(forKey: snapshotCacheKey) else {
+ return nil
+ }
+
+ let timestampValue = defaults.double(forKey: snapshotTimestampKey)
+ guard timestampValue > 0 else {
+ defaults.removeObject(forKey: snapshotCacheKey)
+ defaults.removeObject(forKey: snapshotTimestampKey)
+ return nil
+ }
+
+ return (html, Date(timeIntervalSince1970: timestampValue))
+ }
+
+ func saveSnapshotHTML(_ html: String, timestamp: Date) {
+ defaults.set(html, forKey: snapshotCacheKey)
+ defaults.set(timestamp.timeIntervalSince1970, forKey: snapshotTimestampKey)
+ }
+
+ func loadIncidentFeedData() -> (data: Data, timestamp: Date)? {
+ guard let data = defaults.data(forKey: incidentCacheKey) else {
+ return nil
+ }
+
+ let timestampValue = defaults.double(forKey: incidentTimestampKey)
+ guard timestampValue > 0 else {
+ defaults.removeObject(forKey: incidentCacheKey)
+ defaults.removeObject(forKey: incidentTimestampKey)
+ return nil
+ }
+
+ return (data, Date(timeIntervalSince1970: timestampValue))
+ }
+
+ func saveIncidentFeedData(_ data: Data, timestamp: Date) {
+ defaults.set(data, forKey: incidentCacheKey)
+ defaults.set(timestamp.timeIntervalSince1970, forKey: incidentTimestampKey)
+ }
+
+ private var snapshotTimestampKey: String {
+ "\(snapshotCacheKey).timestamp"
+ }
+
+ private var incidentTimestampKey: String {
+ "\(incidentCacheKey).timestamp"
+ }
+}
diff --git a/Hutch/Networking/SystemStatusRepository.swift b/Hutch/Networking/SystemStatusRepository.swift
index 48c5b16..5f51315 100644
--- a/Hutch/Networking/SystemStatusRepository.swift
+++ b/Hutch/Networking/SystemStatusRepository.swift
@@ -1,57 +1,193 @@
import Foundation
+protocol SystemStatusServing: Sendable {
+ func fetchSnapshotHTML() async throws -> String
+ func fetchIncidentFeedData() async throws -> Data
+}
+
+struct CachedSystemStatusValue<Value: Sendable>: Sendable {
+ let value: Value
+ let lastSuccessfulAt: Date
+ let isStale: Bool
+ let refreshErrorMessage: String?
+}
+
actor SystemStatusRepository {
- private let service: SystemStatusService
+ private let service: any SystemStatusServing
private let ttl: TimeInterval
+ private let cacheStore: SystemStatusCacheStore
+ private let now: @Sendable () -> Date
private var snapshotCache: CacheEntry<SystemStatusSnapshot>?
private var incidentsCache: CacheEntry<[StatusIncident]>?
+ private var hasLoadedPersistentCache = false
- init(service: SystemStatusService = SystemStatusService(), ttl: TimeInterval = 10 * 60) {
+ init(
+ service: any SystemStatusServing = SystemStatusService(),
+ ttl: TimeInterval = 10 * 60,
+ cacheStore: SystemStatusCacheStore = SystemStatusCacheStore(),
+ now: @escaping @Sendable () -> Date = Date.init
+ ) {
self.service = service
self.ttl = ttl
+ self.cacheStore = cacheStore
+ self.now = now
}
func snapshot(forceRefresh: Bool = false) async throws -> SystemStatusSnapshot {
- if let cached = snapshotCache, !forceRefresh, !cached.isExpired(ttl: ttl) {
- return cached.value
+ try await snapshotResult(forceRefresh: forceRefresh).value
+ }
+
+ func recentIncidents(forceRefresh: Bool = false) async throws -> [StatusIncident] {
+ try await recentIncidentsResult(forceRefresh: forceRefresh).value
+ }
+
+ func snapshotResult(forceRefresh: Bool = false) async throws -> CachedSystemStatusValue<SystemStatusSnapshot> {
+ await loadPersistentCacheIfNeeded()
+
+ if let cached = snapshotCache, !forceRefresh, !cached.isExpired(ttl: ttl, now: now) {
+ return CachedSystemStatusValue(
+ value: cached.value,
+ lastSuccessfulAt: cached.timestamp,
+ isStale: false,
+ refreshErrorMessage: nil
+ )
}
do {
- let snapshot = try await service.fetchSnapshot()
- snapshotCache = CacheEntry(value: snapshot, timestamp: Date())
- return snapshot
+ let html = try await service.fetchSnapshotHTML()
+ let snapshot = try SystemStatusService.parseSnapshotHTML(html, fetchedAt: now())
+ let entry = CacheEntry(value: snapshot, timestamp: now())
+ snapshotCache = entry
+ await cacheStore.saveSnapshotHTML(html, timestamp: entry.timestamp)
+ return CachedSystemStatusValue(
+ value: snapshot,
+ lastSuccessfulAt: entry.timestamp,
+ isStale: false,
+ refreshErrorMessage: nil
+ )
} catch {
if let cached = snapshotCache {
- return cached.value
+ return CachedSystemStatusValue(
+ value: cached.value,
+ lastSuccessfulAt: cached.timestamp,
+ isStale: true,
+ refreshErrorMessage: refreshErrorMessage(from: error)
+ )
}
throw error
}
}
- func recentIncidents(forceRefresh: Bool = false) async throws -> [StatusIncident] {
- if let cached = incidentsCache, !forceRefresh, !cached.isExpired(ttl: ttl) {
- return cached.value
+ func recentIncidentsResult(forceRefresh: Bool = false) async throws -> CachedSystemStatusValue<[StatusIncident]> {
+ await loadPersistentCacheIfNeeded()
+
+ if let cached = incidentsCache, !forceRefresh, !cached.isExpired(ttl: ttl, now: now) {
+ return CachedSystemStatusValue(
+ value: cached.value,
+ lastSuccessfulAt: cached.timestamp,
+ isStale: false,
+ refreshErrorMessage: nil
+ )
}
do {
- let incidents = try await service.fetchIncidentFeed()
- incidentsCache = CacheEntry(value: incidents, timestamp: Date())
- return incidents
+ let feedData = try await service.fetchIncidentFeedData()
+ let incidents = try await SystemStatusService.parseIncidentFeedXML(feedData)
+ let entry = CacheEntry(value: incidents, timestamp: now())
+ incidentsCache = entry
+ await cacheStore.saveIncidentFeedData(feedData, timestamp: entry.timestamp)
+ return CachedSystemStatusValue(
+ value: incidents,
+ lastSuccessfulAt: entry.timestamp,
+ isStale: false,
+ refreshErrorMessage: nil
+ )
} catch {
if let cached = incidentsCache {
- return cached.value
+ return CachedSystemStatusValue(
+ value: cached.value,
+ lastSuccessfulAt: cached.timestamp,
+ isStale: true,
+ refreshErrorMessage: refreshErrorMessage(from: error)
+ )
}
throw error
}
}
+
+ private func loadPersistentCacheIfNeeded() async {
+ guard !hasLoadedPersistentCache else { return }
+ if let persistedSnapshot = await cacheStore.loadSnapshotHTML(),
+ let snapshot = try? SystemStatusService.parseSnapshotHTML(persistedSnapshot.html, fetchedAt: persistedSnapshot.timestamp) {
+ snapshotCache = CacheEntry(value: snapshot, timestamp: persistedSnapshot.timestamp)
+ }
+ if let persistedFeed = await cacheStore.loadIncidentFeedData(),
+ let incidents = try? await SystemStatusService.parseIncidentFeedXML(persistedFeed.data) {
+ incidentsCache = CacheEntry(value: incidents, timestamp: persistedFeed.timestamp)
+ }
+ hasLoadedPersistentCache = true
+ }
+
+ private func refreshErrorMessage(from error: any Error) -> String {
+ if let error = error as? SRHTError {
+ switch error {
+ case .graphQLErrors(let errors):
+ let firstMessage = errors.first?.message.lowercased() ?? ""
+ if firstMessage.contains("unauthorized") || firstMessage.contains("forbidden") {
+ return "You do not have permission to do that."
+ }
+ if firstMessage.contains("not found") || firstMessage.contains("no rows in result set") {
+ return "That content is no longer available."
+ }
+ return "Something went wrong. Please try again."
+ case .httpError(let code):
+ if code == 401 {
+ return "Please sign in again."
+ }
+ if code == 403 {
+ return "You do not have permission to do that."
+ }
+ if code == 404 {
+ return "That content is no longer available."
+ }
+ if (500...599).contains(code) {
+ return "The server is unavailable right now. Please try again."
+ }
+ return "Something went wrong. Please try again."
+ case .invalidAuthenticatedURL:
+ return "That request could not be completed."
+ case .decodingError:
+ return "The response could not be loaded right now."
+ case .networkError(let underlyingError):
+ return refreshErrorMessage(from: underlyingError)
+ case .unauthorized:
+ return "Please sign in again."
+ }
+ }
+
+ let nsError = error as NSError
+ switch nsError.code {
+ case NSURLErrorNotConnectedToInternet,
+ NSURLErrorNetworkConnectionLost,
+ NSURLErrorTimedOut,
+ NSURLErrorCannotFindHost,
+ NSURLErrorCannotConnectToHost,
+ NSURLErrorDNSLookupFailed,
+ NSURLErrorInternationalRoamingOff,
+ NSURLErrorDataNotAllowed:
+ return "Check your connection and try again."
+ default:
+ return "Something went wrong. Please try again."
+ }
+ }
}
-private struct CacheEntry<Value: Sendable>: Sendable {
+struct CacheEntry<Value: Sendable>: Sendable {
let value: Value
let timestamp: Date
- nonisolated func isExpired(ttl: TimeInterval) -> Bool {
- Date().timeIntervalSince(timestamp) > ttl
+ nonisolated func isExpired(ttl: TimeInterval, now: @escaping @Sendable () -> Date = Date.init) -> Bool {
+ now().timeIntervalSince(timestamp) > ttl
}
}
diff --git a/Hutch/Networking/SystemStatusService.swift b/Hutch/Networking/SystemStatusService.swift
index fd38fd3..5dd27b5 100644
--- a/Hutch/Networking/SystemStatusService.swift
+++ b/Hutch/Networking/SystemStatusService.swift
@@ -13,15 +13,23 @@ struct SystemStatusService: Sendable {
}
func fetchSnapshot() async throws -> SystemStatusSnapshot {
- let html = try await fetchText(from: Self.statusURL, accept: "text/html,application/xhtml+xml")
+ let html = try await fetchSnapshotHTML()
return try Self.parseSnapshotHTML(html, fetchedAt: now())
}
func fetchIncidentFeed() async throws -> [StatusIncident] {
- let data = try await fetchData(from: Self.feedURL, accept: "application/rss+xml,application/xml,text/xml")
+ let data = try await fetchIncidentFeedData()
return try await Self.parseIncidentFeedXML(data)
}
+ func fetchSnapshotHTML() async throws -> String {
+ try await fetchText(from: Self.statusURL, accept: "text/html,application/xhtml+xml")
+ }
+
+ func fetchIncidentFeedData() async throws -> Data {
+ try await fetchData(from: Self.feedURL, accept: "application/rss+xml,application/xml,text/xml")
+ }
+
private func fetchText(from url: URL, accept: String) async throws -> String {
let data = try await fetchData(from: url, accept: accept)
guard let text = String(data: data, encoding: .utf8) else {
@@ -62,6 +70,8 @@ struct SystemStatusService: Sendable {
}
}
+extension SystemStatusService: SystemStatusServing {}
+
extension SystemStatusService {
nonisolated static func parseSnapshotHTML(_ html: String, fetchedAt: Date) throws -> SystemStatusSnapshot {
let services = parseServices(in: html)
@@ -96,7 +106,7 @@ extension SystemStatusService {
nonisolated private static func parseServices(in html: String) -> [StatusServiceState] {
firstMatches(
in: html,
- pattern: #"<div class="component" data-status="([^"]+)">([\s\S]*?)</div>"#
+ pattern: #"<div\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bcomponent\b[^"']*["'])(?=[^>]*\bdata-status\s*=\s*["']([^"']+)["'])[^>]*>([\s\S]*?)</div>"#
).compactMap { captures in
guard captures.count >= 2 else { return nil }
@@ -104,16 +114,18 @@ extension SystemStatusService {
let content = captures[1]
guard let linkCaptures = firstMatches(
in: content,
- pattern: #"<a[^>]*href="([^"]+)"[^>]*>\s*(.*?)\s*</a>"#
+ pattern: #"<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)</a>"#
).first,
- linkCaptures.count >= 2,
- let statusText = firstMatch(in: content, pattern: #"<span class="component-status">\s*(.*?)\s*</span>"#) else {
+ linkCaptures.count >= 2 else {
return nil
}
let href = linkCaptures[0]
let cleanedName = cleanText(linkCaptures[1])
- let readableStatus = cleanText(statusText)
+ let readableStatus = firstMatch(
+ in: content,
+ pattern: #"<(?:span|small|div)\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bcomponent-status\b[^"']*["'])[^>]*>([\s\S]*?)</(?:span|small|div)>"#
+ ).map(cleanText) ?? firstStatusLabel(in: content) ?? ""
let level = statusLevel(fromHTMLStatus: rawStatus)
return StatusServiceState(
@@ -129,19 +141,19 @@ extension SystemStatusService {
nonisolated private static func parseHTMLIncidentCards(in html: String) -> [StatusIncident] {
firstMatches(
in: html,
- pattern: #"<a href="([^"]+)" class="issue no-underline">([\s\S]*?)</a>"#
+ pattern: #"<a\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bissue\b[^"']*["'])(?=[^>]*\bhref\s*=\s*["']([^"']+)["'])[^>]*>([\s\S]*?)</a>"#
).compactMap { captures in
guard captures.count >= 2 else { return nil }
let href = captures[0]
let content = captures[1]
- guard let titleHTML = firstMatch(in: content, pattern: #"<h3>\s*([\s\S]*?)\s*</h3>"#),
- let titleAttribute = firstMatch(in: content, pattern: #"<small class="date[^"]*" title="([^"]+)">"#),
- let publishedAt = htmlIssueDateFormatter.date(from: cleanText(titleAttribute)) else {
+ guard let titleHTML = firstMatch(in: content, pattern: #"<h[1-6][^>]*>\s*([\s\S]*?)\s*</h[1-6]>"#)
+ ?? firstMatch(in: content, pattern: #"<strong[^>]*>\s*([\s\S]*?)\s*</strong>"#)
+ ?? firstMatch(in: content, pattern: #"<span[^>]*>\s*([\s\S]*?)\s*</span>"#),
+ let publishedAt = publishedIncidentDate(in: content) else {
return nil
}
let url = URL(string: href, relativeTo: statusURL)?.absoluteURL
- let isActive = content.localizedCaseInsensitiveContains("This issue is not resolved yet")
return StatusIncident(
id: url?.absoluteString ?? cleanText(titleHTML),
title: cleanText(titleHTML),
@@ -149,7 +161,7 @@ extension SystemStatusService {
url: url,
publishedAt: publishedAt,
updatedAt: nil,
- isActive: isActive
+ isActive: isActiveIncidentCard(content)
)
}
}
@@ -157,12 +169,12 @@ extension SystemStatusService {
nonisolated private static func parseActiveIncidentSummaries(in html: String) -> [String: String] {
firstMatches(
in: html,
- pattern: #"<div class="announcement-box"[\s\S]*?<div class="padding">([\s\S]*?)</div>\s*<hr class="clean announcement-box">"#
+ pattern: #"<div\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bannouncement-box\b[^"']*["'])[^>]*>([\s\S]*?)</div>\s*(?:<hr\b[^>]*\bannouncement-box\b[^>]*>)?"#
).reduce(into: [:]) { partialResult, captures in
guard let content = captures.first,
let titleLinkCaptures = firstMatches(
in: content,
- pattern: #"<a href="([^"]+)"><strong class="bold">([\s\S]*?)</strong></a>"#
+ pattern: #"<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)</a>"#
).first,
let href = titleLinkCaptures.first else {
return
@@ -197,17 +209,22 @@ extension SystemStatusService {
}
nonisolated private static func statusLevel(fromLabel label: String) -> StatusLevel {
- switch label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
- case "operational":
- .operational
- case "disrupted", "degraded":
- .degraded
- case "down", "major outage":
- .majorOutage
- case "maintenance":
- .maintenance
+ let normalizedLabel = label
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+
+ return switch normalizedLabel {
+ case "operational", "all systems operational":
+ StatusLevel.operational
+ case "disrupted", "degraded", "partial outage":
+ StatusLevel.degraded
+ case "down", "major outage", "outage":
+ StatusLevel.majorOutage
+ case "maintenance", "scheduled maintenance", "under maintenance":
+ StatusLevel.maintenance
default:
- .unknown
+ StatusLevel.unknown
}
}
@@ -252,6 +269,47 @@ extension SystemStatusService {
.trimmingCharacters(in: .whitespacesAndNewlines)
}
+ nonisolated private static func firstStatusLabel(in text: String) -> String? {
+ cleanText(text)
+ .split(separator: "•")
+ .map(String.init)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .first(where: { statusLevel(fromLabel: $0) != .unknown })
+ }
+
+ nonisolated private static func publishedIncidentDate(in content: String) -> Date? {
+ if let timeValue = firstMatch(in: content, pattern: #"<time\b[^>]*\bdatetime\s*=\s*["']([^"']+)["'][^>]*>"#),
+ let parsed = parseISO8601Date(cleanText(timeValue)) {
+ return parsed
+ }
+
+ if let titleAttribute = firstMatch(
+ in: content,
+ pattern: #"<(?:small|time)\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bdate\b[^"']*["'])[^>]*\btitle\s*=\s*["']([^"']+)["'][^>]*>"#
+ ) ?? firstMatch(in: content, pattern: #"\btitle\s*=\s*["']([^"']+UTC)["']"#) {
+ return htmlIssueDateFormatter.date(from: cleanText(titleAttribute))
+ }
+
+ return nil
+ }
+
+ nonisolated private static func isActiveIncidentCard(_ content: String) -> Bool {
+ let normalizedContent = cleanText(content).lowercased()
+ return normalizedContent.contains("not resolved yet")
+ || normalizedContent.contains("ongoing")
+ || normalizedContent.contains("investigating")
+ }
+
+ nonisolated fileprivate static func parseISO8601Date(_ value: String) -> Date? {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return formatter.date(from: value) ?? {
+ let fallbackFormatter = ISO8601DateFormatter()
+ fallbackFormatter.formatOptions = [.withInternetDateTime]
+ return fallbackFormatter.date(from: value)
+ }()
+ }
+
nonisolated private static let htmlIssueDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
@@ -259,9 +317,9 @@ extension SystemStatusService {
formatter.dateFormat = "MMM d HH:mm:ss yyyy zzz"
return formatter
}()
+
}
-@MainActor
private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchecked Sendable {
private var incidents: [StatusIncident] = []
private var currentItem: FeedItem?
@@ -315,7 +373,7 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec
currentItem.guid = value
case "description":
currentItem.description = value
- case "pubDate":
+ case "pubDate", "dc:date":
currentItem.pubDate = value
case "category":
currentItem.category = value
@@ -345,7 +403,7 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec
func makeIncident() -> StatusIncident? {
let cleanedTitle = title.replacingOccurrences(of: "[Resolved] ", with: "")
guard !cleanedTitle.isEmpty,
- let publishedAt = SystemStatusFeedParser.pubDateFormatter.date(from: pubDate) else {
+ let publishedAt = SystemStatusFeedParser.parsePubDate(pubDate) else {
return nil
}
@@ -382,6 +440,10 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec
return decodeHTMLEntities(stripped)
}
+ nonisolated private static func parsePubDate(_ value: String) -> Date? {
+ pubDateFormatter.date(from: value) ?? SystemStatusService.parseISO8601Date(value)
+ }
+
nonisolated private static let pubDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index ab0cdf8..632e804 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -78,19 +78,25 @@ struct HomeView: View {
.refreshable {
await viewModel.loadDashboard()
}
+ .connectivityOverlay(hasContent: viewModel.hasDashboardContent) {
+ await viewModel.loadDashboard()
+ }
}
@ViewBuilder
private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View {
- if let bannerTitle = viewModel.systemStatusBannerTitle {
- Section {
- NavigationLink {
- SystemStatusView()
- } label: {
- HomeSystemStatusBanner(title: bannerTitle)
- }
- .buttonStyle(.plain)
+ Section {
+ NavigationLink {
+ SystemStatusView()
+ } label: {
+ SystemStatusSummaryRow(
+ snapshot: viewModel.systemStatusSnapshot,
+ isLoading: viewModel.isLoadingSystemStatus,
+ errorMessage: viewModel.systemStatusErrorMessage,
+ isShowingStaleData: viewModel.isShowingStaleSystemStatus
+ )
}
+ .buttonStyle(.plain)
}
}
@@ -252,32 +258,6 @@ private struct HomeInboxToolbarIcon: View {
}
}
-private struct HomeSystemStatusBanner: View {
- let title: String
-
- var body: some View {
- HStack(spacing: 12) {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundStyle(.orange)
- VStack(alignment: .leading, spacing: 2) {
- Text("SourceHut service disruption")
- .font(.subheadline.weight(.semibold))
- .foregroundStyle(.primary)
- Text(title)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- }
- Spacer()
- Image(systemName: "chevron.right")
- .font(.caption.weight(.semibold))
- .foregroundStyle(.tertiary)
- }
- .padding(.vertical, 4)
- .contentShape(Rectangle())
- }
-}
-
private struct HomeProjectRow: View {
let project: Project
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index a06df20..ba00a36 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -144,6 +144,9 @@ final class HomeViewModel {
var assignedTickets: [HomeAssignedTicket] = []
var recentBuilds: [HomeBuildItem] = []
private(set) var systemStatusSnapshot: SystemStatusSnapshot?
+ private(set) var isLoadingSystemStatus = false
+ private(set) var isShowingStaleSystemStatus = false
+ private(set) var systemStatusErrorMessage: String?
private(set) var hasUnreadInboxThreads = false
private(set) var unreadInboxThreadCount: Int?
private(set) var isLoadingProjects = false
@@ -279,8 +282,11 @@ final class HomeViewModel {
isLoadingProjects = true
isLoadingAssignedTickets = true
isLoadingRecentBuilds = true
+ isLoadingSystemStatus = true
assignedTicketsError = nil
recentBuildsError = nil
+ isShowingStaleSystemStatus = false
+ systemStatusErrorMessage = nil
async let projectsTask = loadProjects()
async let jobsTask = loadRecentJobs()
@@ -324,13 +330,21 @@ final class HomeViewModel {
unreadInboxThreadCount = await inboxUnreadTask
hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
- systemStatusSnapshot = await systemStatusTask
+ let systemStatusResult = await systemStatusTask
+ switch systemStatusResult {
+ case .success(let result):
+ systemStatusSnapshot = result.value
+ isShowingStaleSystemStatus = result.isStale
+ systemStatusErrorMessage = result.isStale ? result.refreshErrorMessage : nil
+ case .failure(let error):
+ systemStatusErrorMessage = error.userFacingMessage
+ }
+ isLoadingSystemStatus = false
persistNeedsAttentionSnapshot()
}
- var systemStatusBannerTitle: String? {
- guard let systemStatusSnapshot, systemStatusSnapshot.hasDisruption else { return nil }
- return systemStatusSnapshot.bannerSummary
+ var hasDashboardContent: Bool {
+ !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || systemStatusSnapshot != nil
}
func resolveTicket(_ ticket: HomeAssignedTicket) async {
@@ -430,11 +444,11 @@ final class HomeViewModel {
}
}
- private func loadSystemStatusSnapshot() async -> SystemStatusSnapshot? {
+ private func loadSystemStatusSnapshot() async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> {
do {
- return try await systemStatusRepository.snapshot()
+ return .success(try await systemStatusRepository.snapshotResult())
} catch {
- return systemStatusSnapshot
+ return .failure(error)
}
}
diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift
index 284b1ad..6325954 100644
--- a/Hutch/Views/More/MoreView.swift
+++ b/Hutch/Views/More/MoreView.swift
@@ -7,6 +7,7 @@ struct MoreView: View {
("chat.sr.ht", SRHTWebURL.chat)
]
+ @State private var viewModel: MoreViewModel?
@State private var showAccountSwitcher = false
var body: some View {
@@ -29,9 +30,14 @@ struct MoreView: View {
NavigationLink(value: MoreRoute.pastes) {
Label("Pastes", systemImage: "doc.on.clipboard")
}
-
+
NavigationLink(value: MoreRoute.systemStatus) {
- Label("System Status", systemImage: "server.rack")
+ SystemStatusSummaryRow(
+ snapshot: viewModel?.systemStatusSnapshot,
+ isLoading: viewModel?.isLoadingSystemStatus ?? true,
+ errorMessage: viewModel?.systemStatusErrorMessage,
+ isShowingStaleData: viewModel?.isShowingStaleSystemStatus ?? false
+ )
}
}
@@ -58,6 +64,12 @@ struct MoreView: View {
}
}
.navigationTitle("More")
+ .refreshable {
+ await ensureViewModel().loadSystemStatus(forceRefresh: true)
+ }
+ .task {
+ await ensureViewModel().loadIfNeeded()
+ }
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
@@ -71,4 +83,15 @@ struct MoreView: View {
AccountSwitcherView()
}
}
+
+ @MainActor
+ private func ensureViewModel() -> MoreViewModel {
+ if let viewModel {
+ return viewModel
+ }
+
+ let newViewModel = MoreViewModel(repository: appState.systemStatusRepository)
+ viewModel = newViewModel
+ return newViewModel
+ }
}
diff --git a/Hutch/Views/More/MoreViewModel.swift b/Hutch/Views/More/MoreViewModel.swift
new file mode 100644
index 0000000..8f23975
--- /dev/null
+++ b/Hutch/Views/More/MoreViewModel.swift
@@ -0,0 +1,39 @@
+import Foundation
+
+@Observable
+@MainActor
+final class MoreViewModel {
+ private let repository: SystemStatusRepository
+
+ private(set) var systemStatusSnapshot: SystemStatusSnapshot?
+ private(set) var isLoadingSystemStatus = false
+ private(set) var isShowingStaleSystemStatus = false
+ private(set) var systemStatusErrorMessage: String?
+
+ init(repository: SystemStatusRepository) {
+ self.repository = repository
+ }
+
+ func loadIfNeeded() async {
+ guard systemStatusSnapshot == nil, !isLoadingSystemStatus else { return }
+ await loadSystemStatus()
+ }
+
+ func loadSystemStatus(forceRefresh: Bool = false) async {
+ isLoadingSystemStatus = true
+ defer { isLoadingSystemStatus = false }
+ isShowingStaleSystemStatus = false
+ systemStatusErrorMessage = nil
+
+ do {
+ let result = try await repository.snapshotResult(forceRefresh: forceRefresh)
+ systemStatusSnapshot = result.value
+ isShowingStaleSystemStatus = result.isStale
+ systemStatusErrorMessage = result.isStale ? result.refreshErrorMessage : nil
+ } catch {
+ if systemStatusSnapshot == nil {
+ systemStatusErrorMessage = error.userFacingMessage
+ }
+ }
+ }
+}
diff --git a/Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift b/Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift
new file mode 100644
index 0000000..5d94dad
--- /dev/null
+++ b/Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift
@@ -0,0 +1,119 @@
+import SwiftUI
+
+struct SystemStatusSummaryRow: View {
+ let title: String
+ let snapshot: SystemStatusSnapshot?
+ let isLoading: Bool
+ let errorMessage: String?
+ let isShowingStaleData: Bool
+
+ init(
+ title: String = "System Status",
+ snapshot: SystemStatusSnapshot?,
+ isLoading: Bool = false,
+ errorMessage: String? = nil,
+ isShowingStaleData: Bool = false
+ ) {
+ self.title = title
+ self.snapshot = snapshot
+ self.isLoading = isLoading
+ self.errorMessage = errorMessage
+ self.isShowingStaleData = isShowingStaleData
+ }
+
+ var body: some View {
+ HStack(spacing: 12) {
+ icon
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text(title)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.primary)
+
+ Text(primaryMessage)
+ .font(.caption)
+ .foregroundStyle(primaryMessageColor)
+ .lineLimit(2)
+
+ if let metadataMessage {
+ Text(metadataMessage)
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ .lineLimit(1)
+ }
+ }
+
+ Spacer(minLength: 8)
+ }
+ .padding(.vertical, 4)
+ .contentShape(Rectangle())
+ }
+
+ @ViewBuilder
+ private var icon: some View {
+ if isLoading && snapshot == nil {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Image(systemName: iconName)
+ .foregroundStyle(iconColor)
+ }
+ }
+
+ private var primaryMessage: String {
+ if let snapshot {
+ return snapshot.hasDisruption ? snapshot.bannerSummary : snapshot.overallStatusText
+ }
+ if let errorMessage, !errorMessage.isEmpty {
+ return errorMessage
+ }
+ if isLoading {
+ return "Loading system status…"
+ }
+ return "System status is unavailable right now."
+ }
+
+ private var metadataMessage: String? {
+ if let snapshot {
+ if isShowingStaleData {
+ return "Updated \(snapshot.lastUpdated.relativeDescription) • Showing saved data"
+ }
+ return "Updated \(snapshot.lastUpdated.relativeDescription)"
+ }
+ if errorMessage != nil {
+ return "Open System Status to retry."
+ }
+ return nil
+ }
+
+ private var iconName: String {
+ if let snapshot {
+ return snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill"
+ }
+ if errorMessage != nil {
+ return "exclamationmark.triangle"
+ }
+ return "server.rack"
+ }
+
+ private var iconColor: Color {
+ if let snapshot {
+ return snapshot.hasDisruption ? .orange : .green
+ }
+ if errorMessage != nil {
+ return .secondary
+ }
+ return .secondary
+ }
+
+ private var primaryMessageColor: Color {
+ if snapshot != nil {
+ return .secondary
+ }
+ if errorMessage != nil {
+ return .secondary
+ }
+ return .secondary
+ }
+}
diff --git a/Hutch/Views/SystemStatus/SystemStatusView.swift b/Hutch/Views/SystemStatus/SystemStatusView.swift
index 053210e..08a46eb 100644
--- a/Hutch/Views/SystemStatus/SystemStatusView.swift
+++ b/Hutch/Views/SystemStatus/SystemStatusView.swift
@@ -31,6 +31,14 @@ struct SystemStatusView: View {
@ViewBuilder
private func content(_ viewModel: SystemStatusViewModel) -> some View {
List {
+ if viewModel.isShowingStaleData, let staleDataMessage = viewModel.staleDataMessage {
+ Section {
+ Label(staleDataMessage, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+
if let snapshot = viewModel.snapshot {
summarySection(snapshot)
servicesSection(snapshot)
diff --git a/Hutch/Views/SystemStatus/SystemStatusViewModel.swift b/Hutch/Views/SystemStatus/SystemStatusViewModel.swift
index 646d7ca..7700208 100644
--- a/Hutch/Views/SystemStatus/SystemStatusViewModel.swift
+++ b/Hutch/Views/SystemStatus/SystemStatusViewModel.swift
@@ -8,6 +8,8 @@ final class SystemStatusViewModel {
private(set) var snapshot: SystemStatusSnapshot?
private(set) var recentIncidents: [StatusIncident] = []
private(set) var isLoading = false
+ private(set) var isShowingStaleData = false
+ private(set) var staleDataMessage: String?
var errorMessage: String?
init(repository: SystemStatusRepository) {
@@ -25,12 +27,24 @@ final class SystemStatusViewModel {
defer { isLoading = false }
errorMessage = nil
+ staleDataMessage = nil
+ isShowingStaleData = false
- async let snapshotTask = repository.snapshot(forceRefresh: forceRefresh)
- async let incidentsTask = repository.recentIncidents(forceRefresh: forceRefresh)
+ async let snapshotTask = repository.snapshotResult(forceRefresh: forceRefresh)
+ async let incidentsTask = repository.recentIncidentsResult(forceRefresh: forceRefresh)
+
+ var refreshWarnings: [String] = []
do {
- snapshot = try await snapshotTask
+ let result = try await snapshotTask
+ snapshot = result.value
+ if result.isStale {
+ isShowingStaleData = true
+ staleDataMessage = "Showing the last saved system status snapshot."
+ if let warning = result.refreshErrorMessage {
+ refreshWarnings.append(warning)
+ }
+ }
} catch {
if snapshot == nil {
errorMessage = error.userFacingMessage
@@ -38,11 +52,23 @@ final class SystemStatusViewModel {
}
do {
- recentIncidents = try await incidentsTask
+ let result = try await incidentsTask
+ recentIncidents = result.value
+ if result.isStale {
+ isShowingStaleData = true
+ staleDataMessage = staleDataMessage ?? "Showing the last saved incident history."
+ if let warning = result.refreshErrorMessage {
+ refreshWarnings.append(warning)
+ }
+ }
} catch {
if errorMessage == nil && recentIncidents.isEmpty {
errorMessage = error.userFacingMessage
}
}
+
+ if hasContent, let firstWarning = refreshWarnings.first {
+ errorMessage = "Showing cached system status. \(firstWarning)"
+ }
}
}
diff --git a/HutchTests/AppStateTests.swift b/HutchTests/AppStateTests.swift
index ca34df2..9cf4e13 100644
--- a/HutchTests/AppStateTests.swift
+++ b/HutchTests/AppStateTests.swift
@@ -22,4 +22,15 @@ struct AppStateTests {
#expect(appState.deepLinkError == "The ticket could not be found or is inaccessible.")
}
+
+ @Test
+ @MainActor
+ func openSystemStatusSelectsMoreTabAndQueuesNavigation() {
+ let appState = AppState()
+
+ appState.openSystemStatus()
+
+ #expect(appState.selectedTab == .more)
+ #expect(appState.pendingTabNavigation == .systemStatus)
+ }
}
diff --git a/HutchTests/SystemStatusRepositoryTests.swift b/HutchTests/SystemStatusRepositoryTests.swift
new file mode 100644
index 0000000..fb08732
--- /dev/null
+++ b/HutchTests/SystemStatusRepositoryTests.swift
@@ -0,0 +1,147 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct SystemStatusRepositoryTests {
+
+ @Test
+ func fallsBackToPersistedSnapshotWhenRefreshFails() async throws {
+ let defaultsName = "SystemStatusRepositoryTests-\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: defaultsName))
+ defaults.removePersistentDomain(forName: defaultsName)
+ defer { defaults.removePersistentDomain(forName: defaultsName) }
+
+ let cachedSnapshot = SystemStatusSnapshot(
+ services: [
+ StatusServiceState(id: "git.sr.ht", name: "git.sr.ht", slug: "git.sr.ht", status: .degraded, description: nil)
+ ],
+ activeIncidents: [],
+ lastUpdated: Date(timeIntervalSince1970: 120)
+ )
+ let cacheStore = SystemStatusCacheStore(defaults: defaults)
+ let initialRepository = SystemStatusRepository(
+ service: TestSystemStatusService(
+ snapshotHTMLHandler: { Self.cachedSnapshotHTML },
+ incidentsDataHandler: { Data(Self.emptyRSS.utf8) }
+ ),
+ cacheStore: cacheStore,
+ now: { Date(timeIntervalSince1970: 120) }
+ )
+
+ _ = try await initialRepository.snapshotResult(forceRefresh: true)
+
+ let fallbackRepository = SystemStatusRepository(
+ service: TestSystemStatusService(
+ snapshotHTMLHandler: { throw SRHTError.httpError(503) },
+ incidentsDataHandler: { Data(Self.emptyRSS.utf8) }
+ ),
+ ttl: 0,
+ cacheStore: cacheStore,
+ now: { Date(timeIntervalSince1970: 180) }
+ )
+
+ let result = try await fallbackRepository.snapshotResult(forceRefresh: true)
+
+ #expect(result.value == cachedSnapshot)
+ #expect(result.isStale)
+ #expect(result.lastSuccessfulAt == Date(timeIntervalSince1970: 120))
+ #expect(result.refreshErrorMessage != nil)
+ }
+
+ @Test
+ func fallsBackToPersistedIncidentsWhenRefreshFails() async throws {
+ let defaultsName = "SystemStatusRepositoryTests-\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: defaultsName))
+ defaults.removePersistentDomain(forName: defaultsName)
+ defer { defaults.removePersistentDomain(forName: defaultsName) }
+
+ let cachedIncidents = [
+ StatusIncident(
+ id: "incident-1",
+ title: "builds.sr.ht outage",
+ summary: "Builds are failing.",
+ url: nil,
+ publishedAt: Date(timeIntervalSince1970: 200),
+ updatedAt: nil,
+ isActive: true
+ )
+ ]
+ let cacheStore = SystemStatusCacheStore(defaults: defaults)
+ let initialRepository = SystemStatusRepository(
+ service: TestSystemStatusService(
+ snapshotHTMLHandler: { Self.cachedOperationalHTML },
+ incidentsDataHandler: { Data(Self.cachedIncidentRSS.utf8) }
+ ),
+ cacheStore: cacheStore,
+ now: { Date(timeIntervalSince1970: 220) }
+ )
+
+ _ = try await initialRepository.recentIncidentsResult(forceRefresh: true)
+
+ let fallbackRepository = SystemStatusRepository(
+ service: TestSystemStatusService(
+ snapshotHTMLHandler: { Self.cachedOperationalHTML },
+ incidentsDataHandler: { throw SRHTError.httpError(504) }
+ ),
+ ttl: 0,
+ cacheStore: cacheStore,
+ now: { Date(timeIntervalSince1970: 260) }
+ )
+
+ let result = try await fallbackRepository.recentIncidentsResult(forceRefresh: true)
+
+ #expect(result.value == cachedIncidents)
+ #expect(result.isStale)
+ #expect(result.lastSuccessfulAt == Date(timeIntervalSince1970: 220))
+ #expect(result.refreshErrorMessage != nil)
+ }
+}
+
+private struct TestSystemStatusService: SystemStatusServing {
+ let snapshotHTMLHandler: @Sendable () async throws -> String
+ let incidentsDataHandler: @Sendable () async throws -> Data
+
+ func fetchSnapshotHTML() async throws -> String {
+ try await snapshotHTMLHandler()
+ }
+
+ func fetchIncidentFeedData() async throws -> Data {
+ try await incidentsDataHandler()
+ }
+}
+
+private extension SystemStatusRepositoryTests {
+ static let cachedSnapshotHTML = #"""
+ <div class="component" data-status="disrupted">
+ <a href="/affected/git.sr.ht/">git.sr.ht</a>
+ <span class="component-status">Disrupted</span>
+ </div>
+ """#
+
+ static let cachedOperationalHTML = #"""
+ <div class="component" data-status="ok">
+ <a href="/affected/meta.sr.ht/">meta.sr.ht</a>
+ <span class="component-status">Operational</span>
+ </div>
+ """#
+
+ static let cachedIncidentRSS = #"""
+ <rss version="2.0">
+ <channel>
+ <item>
+ <title>builds.sr.ht outage</title>
+ <link>https://status.sr.ht/issues/1/</link>
+ <pubDate>Thu, 01 Jan 1970 00:03:20 +0000</pubDate>
+ <guid>incident-1</guid>
+ <description>&lt;p&gt;Builds are failing.&lt;/p&gt;</description>
+ </item>
+ </channel>
+ </rss>
+ """#
+
+ static let emptyRSS = #"""
+ <rss version="2.0">
+ <channel></channel>
+ </rss>
+ """#
+}
diff --git a/HutchTests/SystemStatusServiceTests.swift b/HutchTests/SystemStatusServiceTests.swift
index 58ace33..d1577b6 100644
--- a/HutchTests/SystemStatusServiceTests.swift
+++ b/HutchTests/SystemStatusServiceTests.swift
@@ -20,6 +20,18 @@ struct SystemStatusServiceTests {
}
@Test
+ func parsesStatusHTMLWithClassOrderChangesAndTimeElements() throws {
+ let snapshot = try SystemStatusService.parseSnapshotHTML(Self.variantHTML, fetchedAt: .now)
+
+ #expect(snapshot.services.count == 2)
+ #expect(snapshot.services[0].status == .operational)
+ #expect(snapshot.services[1].status == .majorOutage)
+ #expect(snapshot.activeIncidents.count == 1)
+ #expect(snapshot.activeIncidents[0].title == "builds.sr.ht outage")
+ #expect(snapshot.activeIncidents[0].publishedAt == ISO8601DateFormatter().date(from: "2026-04-07T12:00:00Z"))
+ }
+
+ @Test
func parsesIncidentFeedRSS() async throws {
let incidents = try await SystemStatusService.parseIncidentFeedXML(Data(Self.sampleRSS.utf8))
@@ -33,6 +45,15 @@ struct SystemStatusServiceTests {
}
@Test
+ func parsesIncidentFeedWithISO8601Dates() async throws {
+ let incidents = try await SystemStatusService.parseIncidentFeedXML(Data(Self.variantRSS.utf8))
+
+ #expect(incidents.count == 1)
+ #expect(incidents[0].title == "Status feed moved")
+ #expect(incidents[0].publishedAt == ISO8601DateFormatter().date(from: "2026-04-07T15:30:00Z"))
+ }
+
+ @Test
func bannerSummaryPrefersSpecificServiceThenCount() {
let operational = SystemStatusSnapshot(
services: [
@@ -102,6 +123,32 @@ struct SystemStatusServiceTests {
</html>
"""#
+ private static let variantHTML = #"""
+ <html>
+ <body>
+ <div class="component extra" data-status="ok">
+ <a class="no-underline" href="/affected/meta.sr.ht/">meta.sr.ht</a>
+ <small class="component-status secondary">All systems operational</small>
+ </div>
+ <div data-status="down" class="extra component">
+ <a href="/affected/builds.sr.ht/" class="link">builds.sr.ht</a>
+ <div class="component-status badge">Outage</div>
+ </div>
+ <div class="announcement-box">
+ <div class="padding">
+ <p><a href="/issues/2026-04-07-builds-outage/"><strong>builds.sr.ht outage</strong></a></p>
+ <p><strong>Build jobs are currently failing.</strong></p>
+ </div>
+ </div>
+ <a class="issue no-underline urgent" href="/issues/2026-04-07-builds-outage/">
+ <time class="date" datetime="2026-04-07T12:00:00Z">Apr 7</time>
+ <h4>builds.sr.ht outage</h4>
+ <span>Investigating elevated failures</span>
+ </a>
+ </body>
+ </html>
+ """#
+
private static let sampleRSS = #"""
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0">
@@ -126,4 +173,20 @@ struct SystemStatusServiceTests {
</channel>
</rss>
"""#
+
+ private static let variantRSS = #"""
+ <?xml version="1.0" encoding="utf-8" standalone="yes"?>
+ <rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
+ <channel>
+ <title>sr.ht status</title>
+ <item>
+ <title>Status feed moved</title>
+ <link>https://status.sr.ht/issues/2026-04-07-feed-moved/</link>
+ <dc:date>2026-04-07T15:30:00Z</dc:date>
+ <guid>https://status.sr.ht/issues/2026-04-07-feed-moved/</guid>
+ <description>&lt;p&gt;Use the new feed endpoint.&lt;/p&gt;</description>
+ </item>
+ </channel>
+ </rss>
+ """#
}