krz/octosentry

macOS menu bar app to monitor GitHub security alerts

clone: git clone https://gitbay.org/krz/octosentry.git

d2c42c2a5cd10ae144b715dec80a2555d2bbc1bc

signed_unknown_key

author: Christian Cleberg <hello@cleberg.net> · 2026-08-22T21:15:24Z
committer: <noreply@github.com>

Add alert history and trends (#32)

Records two things per complete poll: a periodic snapshot of open
counts by source and severity, and a per-alert lifecycle carrying when
GitHub opened the alert, when it was last seen, and when it stopped
being reported. Surfaced in the dedicated window as open-now, new and
resolved over 30 days, mean time to resolution, and an open-count
chart.

Retention, which the issue left open: snapshots are taken at most every
six hours and kept 90 days, so the series is bounded at roughly 360
entries whatever the poll interval; resolved lifecycles are dropped
after 90 days while open ones are kept. A test simulates a year of
15-minute polling and asserts the bound holds.

Storage stays flat JSON rather than SwiftData. With retention the
history is tens of kilobytes, nothing queries it except the view
loading it whole, and it inherits PersistenceStore's existing
handling of an unreadable file.

Time to resolution measures from GitHub's created_at, not from when
octosentry first polled, so it doesn't restart at install day. Only
polls where every watched repo answered are recorded — a missing repo
is not a set of resolved alerts.

Closes #21
 octosentry/AlertHistory.swift             | 143 ++++++++++++++++++++
 octosentry/AlertHistoryView.swift         |  99 ++++++++++++++
 octosentry/PersistedState.swift           |  11 +-
 octosentry/SecurityEventListView.swift    |  15 ++-
 octosentry/SecurityEventStore.swift       |  13 +-
 octosentryTests/AlertHistoryTests.swift   | 217 ++++++++++++++++++++++++++++++
 octosentryTests/PersistedStateTests.swift |   3 +-
 7 files changed, 495 insertions(+), 6 deletions(-)

diff --git a/octosentry/AlertHistory.swift b/octosentry/AlertHistory.swift
new file mode 100644
index 0000000..60e5c90
--- /dev/null
+++ b/octosentry/AlertHistory.swift
@@ -0,0 +1,143 @@
+//
+//  AlertHistory.swift
+//  octosentry
+//
+//  A record of what the feed looked like over time, so the app can answer
+//  "are we getting better or worse?" and "how long do alerts sit open?"
+//
+//  Two parts, because they answer different questions: periodic snapshots
+//  give the open-count trend, and a per-alert lifecycle gives new-vs-resolved
+//  and time to resolution.
+//
+//  Both are bounded. Snapshots are taken at most every six hours and kept for
+//  90 days (≈360 entries); resolved lifecycles are dropped after 90 days.
+//  Unbounded growth is the obvious failure mode for a per-poll time series,
+//  and this lives in the same state.json as everything else.
+//
+
+import Foundation
+
+nonisolated struct AlertHistory: Codable, Equatable {
+    static let snapshotInterval: TimeInterval = 6 * 3600
+    static let retention: TimeInterval = 90 * 24 * 3600
+
+    var snapshots: [Snapshot] = []
+    var lifecycles: [String: Lifecycle] = [:]
+
+    nonisolated struct Snapshot: Codable, Equatable {
+        var recordedAt: Date
+        var openCount: Int
+        /// Keyed by SecurityEventSource.rawValue / SecurityEventSeverity
+        /// displayName so the file stays readable.
+        var countsBySource: [String: Int]
+        var countsBySeverity: [String: Int]
+    }
+
+    nonisolated struct Lifecycle: Codable, Equatable {
+        var repoFullName: String
+        var source: String
+        var severity: SecurityEventSeverity
+        /// When GitHub opened the alert, not when octosentry first saw it —
+        /// otherwise time-to-resolution would be measured from install day.
+        var openedAt: Date
+        var lastSeenAt: Date
+        /// First poll that no longer reported it. "No longer reported" is the
+        /// only resolution signal the API gives.
+        var resolvedAt: Date?
+    }
+
+    /// Folds one complete poll into the history.
+    ///
+    /// `events` must come from a poll where every watched repo answered:
+    /// an alert missing because its repo errored is not a resolved alert.
+    mutating func record(_ events: [SecurityEvent], at date: Date) {
+        let presentIDs = Set(events.map(\.id))
+
+        for event in events {
+            if var lifecycle = lifecycles[event.id] {
+                lifecycle.lastSeenAt = date
+                lifecycle.severity = event.severity
+                // Back from the dead: GitHub re-reported it.
+                lifecycle.resolvedAt = nil
+                lifecycles[event.id] = lifecycle
+            } else {
+                lifecycles[event.id] = Lifecycle(
+                    repoFullName: event.repoFullName,
+                    source: event.source.rawValue,
+                    severity: event.severity,
+                    openedAt: event.createdAt,
+                    lastSeenAt: date,
+                    resolvedAt: nil
+                )
+            }
+        }
+
+        for (id, var lifecycle) in lifecycles where !presentIDs.contains(id) && lifecycle.resolvedAt == nil {
+            lifecycle.resolvedAt = date
+            lifecycles[id] = lifecycle
+        }
+
+        appendSnapshot(for: events, at: date)
+        prune(now: date)
+    }
+
+    private mutating func appendSnapshot(for events: [SecurityEvent], at date: Date) {
+        if let last = snapshots.last, date.timeIntervalSince(last.recordedAt) < Self.snapshotInterval {
+            return
+        }
+
+        var countsBySource: [String: Int] = [:]
+        var countsBySeverity: [String: Int] = [:]
+        for event in events {
+            countsBySource[event.source.rawValue, default: 0] += 1
+            countsBySeverity[event.severity.displayName, default: 0] += 1
+        }
+
+        snapshots.append(
+            Snapshot(
+                recordedAt: date,
+                openCount: events.count,
+                countsBySource: countsBySource,
+                countsBySeverity: countsBySeverity
+            )
+        )
+    }
+
+    private mutating func prune(now: Date) {
+        let cutoff = now.addingTimeInterval(-Self.retention)
+        snapshots.removeAll { $0.recordedAt < cutoff }
+        lifecycles = lifecycles.filter { _, lifecycle in
+            guard let resolvedAt = lifecycle.resolvedAt else { return true }
+            return resolvedAt >= cutoff
+        }
+    }
+
+    // MARK: - Trends
+
+    var openCountOverTime: [Snapshot] {
+        snapshots.sorted { $0.recordedAt < $1.recordedAt }
+    }
+
+    func openedCount(since date: Date) -> Int {
+        lifecycles.values.filter { $0.openedAt >= date }.count
+    }
+
+    func resolvedCount(since date: Date) -> Int {
+        lifecycles.values.filter { ($0.resolvedAt ?? .distantFuture) >= date && $0.resolvedAt != nil }.count
+    }
+
+    var currentlyOpenCount: Int {
+        lifecycles.values.filter { $0.resolvedAt == nil }.count
+    }
+
+    /// Mean time from GitHub opening an alert to octosentry no longer seeing
+    /// it. nil when nothing has been resolved yet.
+    var meanTimeToResolution: TimeInterval? {
+        let durations = lifecycles.values.compactMap { lifecycle -> TimeInterval? in
+            guard let resolvedAt = lifecycle.resolvedAt else { return nil }
+            return resolvedAt.timeIntervalSince(lifecycle.openedAt)
+        }
+        guard !durations.isEmpty else { return nil }
+        return durations.reduce(0, +) / Double(durations.count)
+    }
+}
diff --git a/octosentry/AlertHistoryView.swift b/octosentry/AlertHistoryView.swift
new file mode 100644
index 0000000..58af004
--- /dev/null
+++ b/octosentry/AlertHistoryView.swift
@@ -0,0 +1,99 @@
+//
+//  AlertHistoryView.swift
+//  octosentry
+//
+//  Trends over time. Lives in the dedicated window (#10) rather than the
+//  popover — the popover is 380pt wide and meant for at-a-glance triage.
+//
+
+import Charts
+import SwiftUI
+
+struct AlertHistoryView: View {
+    var store: SecurityEventStore
+
+    private static let windowLength: TimeInterval = 30 * 24 * 3600
+
+    private var since: Date {
+        Date().addingTimeInterval(-Self.windowLength)
+    }
+
+    var body: some View {
+        ScrollView {
+            VStack(alignment: .leading, spacing: 16) {
+                summary
+                Divider()
+                openOverTime
+                Divider()
+                resolutionNote
+            }
+            .padding(16)
+            .frame(maxWidth: .infinity, alignment: .leading)
+        }
+    }
+
+    private var summary: some View {
+        HStack(alignment: .top, spacing: 24) {
+            Statistic(label: "Open now", value: "\(store.history.currentlyOpenCount)")
+            Statistic(label: "New (30d)", value: "\(store.history.openedCount(since: since))")
+            Statistic(label: "Resolved (30d)", value: "\(store.history.resolvedCount(since: since))")
+            Statistic(label: "Mean time to resolution", value: meanTimeToResolutionText)
+        }
+    }
+
+    private var meanTimeToResolutionText: String {
+        guard let interval = store.history.meanTimeToResolution else { return "—" }
+        let days = interval / 86_400
+        return days >= 1
+            ? String(format: "%.1f d", days)
+            : String(format: "%.0f h", interval / 3600)
+    }
+
+    @ViewBuilder
+    private var openOverTime: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            Text("Open alerts over time")
+                .font(.subheadline.weight(.semibold))
+
+            let snapshots = store.history.openCountOverTime
+            if snapshots.count < 2 {
+                Text("Not enough history yet — octosentry records a snapshot every few hours.")
+                    .font(.callout)
+                    .foregroundStyle(.secondary)
+            } else {
+                Chart(snapshots, id: \.recordedAt) { snapshot in
+                    LineMark(
+                        x: .value("Date", snapshot.recordedAt),
+                        y: .value("Open", snapshot.openCount)
+                    )
+                    .interpolationMethod(.monotone)
+                }
+                .chartYScale(domain: .automatic(includesZero: true))
+                .frame(height: 180)
+            }
+        }
+    }
+
+    private var resolutionNote: some View {
+        Text("An alert counts as resolved once GitHub stops reporting it — the API gives no other signal, "
+            + "so a repo losing access or leaving the watch list can look the same. Only polls where every "
+            + "watched repo answered are recorded.")
+            .font(.caption)
+            .foregroundStyle(.secondary)
+    }
+}
+
+private struct Statistic: View {
+    let label: String
+    let value: String
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 2) {
+            Text(value)
+                .font(.title2.weight(.semibold))
+            Text(label)
+                .font(.caption)
+                .foregroundStyle(.secondary)
+        }
+    }
+}
diff --git a/octosentry/PersistedState.swift b/octosentry/PersistedState.swift
index 8e4dec2..c7398bb 100644
--- a/octosentry/PersistedState.swift
+++ b/octosentry/PersistedState.swift
@@ -32,9 +32,13 @@ nonisolated struct PersistedState: Codable {
     /// What the user has hidden locally, and until when.
     var triage: AlertTriage
 
+    /// Snapshots and per-alert lifecycles behind the trends view. Bounded by
+    /// AlertHistory's own retention rules.
+    var history: AlertHistory
+
     enum CodingKeys: String, CodingKey {
         case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope, sortOrder
-        case notifiedEventIDsByRepo, triage
+        case notifiedEventIDsByRepo, triage, history
     }
 
     init(
@@ -45,7 +49,8 @@ nonisolated struct PersistedState: Codable {
         hasRepoScope: Bool = false,
         sortOrder: AlertSortOrder = .severity,
         notifiedEventIDsByRepo: [String: Set<String>]? = nil,
-        triage: AlertTriage = AlertTriage()
+        triage: AlertTriage = AlertTriage(),
+        history: AlertHistory = AlertHistory()
     ) {
         self.watchedRepos = watchedRepos
         self.seenEventIDs = seenEventIDs
@@ -55,6 +60,7 @@ nonisolated struct PersistedState: Codable {
         self.sortOrder = sortOrder
         self.notifiedEventIDsByRepo = notifiedEventIDsByRepo
         self.triage = triage
+        self.history = history
     }
 
     // Custom decode so existing state.json files saved before hasRepoScope
@@ -72,6 +78,7 @@ nonisolated struct PersistedState: Codable {
             forKey: .notifiedEventIDsByRepo
         )
         triage = try container.decodeIfPresent(AlertTriage.self, forKey: .triage) ?? AlertTriage()
+        history = try container.decodeIfPresent(AlertHistory.self, forKey: .history) ?? AlertHistory()
     }
 
     static let placeholder = PersistedState(
diff --git a/octosentry/SecurityEventListView.swift b/octosentry/SecurityEventListView.swift
index ab58e74..35ba220 100644
--- a/octosentry/SecurityEventListView.swift
+++ b/octosentry/SecurityEventListView.swift
@@ -13,6 +13,7 @@ struct SecurityEventListView: View {
     var updateStore: UpdateStore
     var isStandaloneWindow: Bool = false
     @State private var showingRepoManager = false
+    @State private var showingHistory = false
     @State private var exportErrorMessage: String?
     @Environment(\.openWindow) private var openWindow
 
@@ -27,6 +28,8 @@ struct SecurityEventListView: View {
                 SignInView(authStore: authStore)
             } else if showingRepoManager {
                 RepoManagerView(store: store, authStore: authStore)
+            } else if showingHistory {
+                AlertHistoryView(store: store)
             } else {
                 filterBar
                 Divider()
@@ -87,7 +90,7 @@ struct SecurityEventListView: View {
 
             Spacer()
 
-            if authStore.isSignedIn && !showingRepoManager {
+            if authStore.isSignedIn && !showingRepoManager && !showingHistory {
                 Picker("Minimum severity", selection: Binding(
                     get: { store.minimumSeverity },
                     set: { newValue in Task { await store.setMinimumSeverity(newValue) } }
@@ -123,7 +126,15 @@ struct SecurityEventListView: View {
             }
 
             if authStore.isSignedIn {
-                if !isStandaloneWindow {
+                if isStandaloneWindow {
+                    Button {
+                        showingHistory.toggle()
+                    } label: {
+                        Image(systemName: showingHistory ? "list.bullet" : "chart.xyaxis.line")
+                    }
+                    .buttonStyle(.plain)
+                    .help(showingHistory ? "Back to alerts" : "Trends")
+                } else {
                     Button {
                         openWindow(id: SecurityEventWindow.id)
                     } label: {
diff --git a/octosentry/SecurityEventStore.swift b/octosentry/SecurityEventStore.swift
index 0ee0665..8b7a2c6 100644
--- a/octosentry/SecurityEventStore.swift
+++ b/octosentry/SecurityEventStore.swift
@@ -38,6 +38,10 @@ final class SecurityEventStore {
     /// so the feed can be re-derived without touching disk.
     private(set) var triage = AlertTriage()
 
+    /// Trend data for the dedicated window (#10). Mirrored from
+    /// PersistedState so the view doesn't touch disk.
+    private(set) var history = AlertHistory()
+
     /// Repos represented in the current fetch, for the repo filter menu —
     /// the watch list can contain repos that returned nothing.
     var reposInFeed: [String] {
@@ -70,6 +74,7 @@ final class SecurityEventStore {
         sortOrder = state.sortOrder
         watchedRepos = state.watchedRepos
         triage = state.triage
+        history = state.history
 
         guard let token = KeychainTokenStore.load() else {
             errorMessages = [stateLoadFailure, GitHubAPIError.missingToken.errorDescription ?? "Not signed in."]
@@ -138,12 +143,18 @@ final class SecurityEventStore {
         // Only prune against a complete picture: if a repo failed this round
         // its alerts are missing, and pruning would forget they were hidden.
         if fetchedEventsByRepo.count == state.watchedRepos.count {
+            let now = Date()
             state.triage = state.triage.pruned(
                 presentEventIDs: Set(fetchedEvents.map(\.id)),
-                now: Date()
+                now: now
             )
             triage = state.triage
             applyFilters()
+
+            // Same completeness rule: an alert missing because its repo
+            // errored has not been resolved.
+            state.history.record(fetchedEvents, at: now)
+            history = state.history
         }
 
         let newEvents = AlertDiff.newlyAppeared(
diff --git a/octosentryTests/AlertHistoryTests.swift b/octosentryTests/AlertHistoryTests.swift
new file mode 100644
index 0000000..56e3482
--- /dev/null
+++ b/octosentryTests/AlertHistoryTests.swift
@@ -0,0 +1,217 @@
+//
+//  AlertHistoryTests.swift
+//  octosentryTests
+//
+
+import Foundation
+import Testing
+@testable import octosentry
+
+struct AlertHistoryTests {
+
+    private let day0 = Date(timeIntervalSince1970: 1_785_000_000)
+    private func days(_ count: Double) -> TimeInterval { count * 86_400 }
+
+    private func event(_ id: String, severity: SecurityEventSeverity = .high, openedDaysAgo: Double = 0) -> SecurityEvent {
+        TestEvents.event(id: id, severity: severity, ageInHours: openedDaysAgo * 24)
+    }
+
+    // MARK: - Lifecycles
+
+    @Test func recordingCreatesALifecyclePerAlert() {
+        var history = AlertHistory()
+        history.record([event("a"), event("b")], at: day0)
+
+        #expect(Set(history.lifecycles.keys) == ["a", "b"])
+        #expect(history.currentlyOpenCount == 2)
+        #expect(history.lifecycles["a"]?.resolvedAt == nil)
+    }
+
+    // openedAt comes from GitHub, not from when octosentry first polled,
+    // otherwise time-to-resolution starts at install day.
+    @Test func lifecycleOpenedAtComesFromTheAlertNotThePoll() {
+        var history = AlertHistory()
+        let alert = event("a", openedDaysAgo: 10)
+        history.record([alert], at: day0)
+
+        #expect(history.lifecycles["a"]?.openedAt == alert.createdAt)
+        #expect(history.lifecycles["a"]?.openedAt != day0)
+    }
+
+    @Test func anAlertThatDisappearsIsMarkedResolved() {
+        var history = AlertHistory()
+        history.record([event("a"), event("b")], at: day0)
+
+        let later = day0.addingTimeInterval(days(1))
+        history.record([event("a")], at: later)
+
+        #expect(history.lifecycles["b"]?.resolvedAt == later)
+        #expect(history.lifecycles["a"]?.resolvedAt == nil)
+        #expect(history.currentlyOpenCount == 1)
+    }
+
+    @Test func resolutionTimeIsNotOverwrittenByLaterPolls() {
+        var history = AlertHistory()
+        history.record([event("a")], at: day0)
+        let resolvedAt = day0.addingTimeInterval(days(1))
+        history.record([], at: resolvedAt)
+        history.record([], at: day0.addingTimeInterval(days(2)))
+
+        #expect(history.lifecycles["a"]?.resolvedAt == resolvedAt)
+    }
+
+    @Test func aReReportedAlertBecomesOpenAgain() {
+        var history = AlertHistory()
+        history.record([event("a")], at: day0)
+        history.record([], at: day0.addingTimeInterval(days(1)))
+        history.record([event("a")], at: day0.addingTimeInterval(days(2)))
+
+        #expect(history.lifecycles["a"]?.resolvedAt == nil)
+        #expect(history.currentlyOpenCount == 1)
+    }
+
+    // MARK: - Snapshots
+
+    @Test func theFirstPollRecordsASnapshot() {
+        var history = AlertHistory()
+        history.record([event("a", severity: .critical), event("b", severity: .low)], at: day0)
+
+        #expect(history.snapshots.count == 1)
+        let snapshot = history.snapshots[0]
+        #expect(snapshot.openCount == 2)
+        #expect(snapshot.countsBySeverity["Critical"] == 1)
+        #expect(snapshot.countsBySeverity["Low"] == 1)
+        #expect(snapshot.countsBySource["dependabot"] == 2)
+    }
+
+    // Polls run every 15 minutes; a snapshot per poll would be 96 a day.
+    @Test func snapshotsAreRateLimited() {
+        var history = AlertHistory()
+        history.record([event("a")], at: day0)
+        history.record([event("a")], at: day0.addingTimeInterval(900))
+        history.record([event("a")], at: day0.addingTimeInterval(3600))
+
+        #expect(history.snapshots.count == 1)
+    }
+
+    @Test func aSnapshotIsTakenOnceTheIntervalHasPassed() {
+        var history = AlertHistory()
+        history.record([event("a")], at: day0)
+        history.record([event("a")], at: day0.addingTimeInterval(AlertHistory.snapshotInterval))
+
+        #expect(history.snapshots.count == 2)
+    }
+
+    @Test func openCountOverTimeIsChronological() {
+        var history = AlertHistory()
+        for step in 0..<4 {
+            history.record([event("a")], at: day0.addingTimeInterval(AlertHistory.snapshotInterval * Double(step)))
+        }
+
+        let dates = history.openCountOverTime.map(\.recordedAt)
+        #expect(dates == dates.sorted())
+    }
+
+    // MARK: - Retention
+
+    @Test func snapshotsOlderThanRetentionAreDropped() {
+        var history = AlertHistory()
+        history.record([event("a")], at: day0)
+        history.record([event("a")], at: day0.addingTimeInterval(AlertHistory.retention + days(1)))
+
+        #expect(history.snapshots.count == 1)
+        #expect(history.snapshots[0].recordedAt > day0)
+    }
+
+    @Test func resolvedLifecyclesAreDroppedAfterRetentionButOpenOnesAreKept() {
+        var history = AlertHistory()
+        history.record([event("old"), event("survivor")], at: day0)
+        history.record([event("survivor")], at: day0.addingTimeInterval(days(1)))
+
+        // Long enough that "old" resolved outside the retention window.
+        history.record([event("survivor")], at: day0.addingTimeInterval(AlertHistory.retention + days(2)))
+
+        #expect(history.lifecycles["old"] == nil)
+        #expect(history.lifecycles["survivor"] != nil)
+    }
+
+    // The growth question: a per-poll series must stay bounded.
+    @Test func aYearOfPollingStaysBounded() {
+        var history = AlertHistory()
+        // Every 15 minutes for 365 days.
+        let pollInterval: TimeInterval = 900
+        var date = day0
+        for _ in 0..<(365 * 96) {
+            history.record([event("a")], at: date)
+            date = date.addingTimeInterval(pollInterval)
+        }
+
+        let maximumSnapshots = Int(AlertHistory.retention / AlertHistory.snapshotInterval) + 2
+        #expect(history.snapshots.count <= maximumSnapshots)
+        #expect(history.snapshots.count > 0)
+    }
+
+    // MARK: - Trends
+
+    @Test func newAndResolvedCountsAreWindowed() {
+        var history = AlertHistory()
+        history.record([event("old", openedDaysAgo: 60), event("recent", openedDaysAgo: 1)], at: day0)
+
+        let since = day0.addingTimeInterval(-days(30))
+        #expect(history.openedCount(since: since) == 1)
+        #expect(history.resolvedCount(since: since) == 0)
+
+        history.record([event("old", openedDaysAgo: 60)], at: day0.addingTimeInterval(days(1)))
+        #expect(history.resolvedCount(since: since) == 1)
+    }
+
+    @Test func meanTimeToResolutionIsNilUntilSomethingResolves() {
+        var history = AlertHistory()
+        history.record([event("a")], at: day0)
+
+        #expect(history.meanTimeToResolution == nil)
+    }
+
+    @Test func meanTimeToResolutionAveragesOpenToResolved() {
+        var history = AlertHistory()
+        // "a" opened 2 days before day0, "b" opened 4 days before.
+        history.record([event("a", openedDaysAgo: 2), event("b", openedDaysAgo: 4)], at: day0)
+        history.record([], at: day0)
+
+        let mean = try? #require(history.meanTimeToResolution)
+        // Resolved at day0, so durations are 2 and 4 days; mean is 3.
+        #expect(mean != nil)
+        if let mean {
+            #expect(abs(mean - days(3)) < 1)
+        }
+    }
+
+    @Test func meanTimeToResolutionIgnoresStillOpenAlerts() {
+        var history = AlertHistory()
+        history.record([event("resolved", openedDaysAgo: 2), event("open", openedDaysAgo: 100)], at: day0)
+        history.record([event("open", openedDaysAgo: 100)], at: day0)
+
+        if let mean = history.meanTimeToResolution {
+            #expect(abs(mean - days(2)) < 1)
+        } else {
+            Issue.record("expected a mean time to resolution")
+        }
+    }
+
+    // MARK: - Persistence
+
+    @Test func roundTripsThroughCodable() throws {
+        var history = AlertHistory()
+        history.record([event("a", severity: .critical)], at: day0)
+        history.record([], at: day0.addingTimeInterval(AlertHistory.snapshotInterval))
+
+        let encoder = JSONEncoder()
+        encoder.dateEncodingStrategy = .iso8601
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .iso8601
+
+        let decoded = try decoder.decode(AlertHistory.self, from: try encoder.encode(history))
+
+        #expect(decoded == history)
+    }
+}
diff --git a/octosentryTests/PersistedStateTests.swift b/octosentryTests/PersistedStateTests.swift
index 47b81b0..a9a92c0 100644
--- a/octosentryTests/PersistedStateTests.swift
+++ b/octosentryTests/PersistedStateTests.swift
@@ -65,7 +65,7 @@ struct PersistedStateTests {
 
         #expect(Set(object.keys) == [
             "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", "sortOrder",
-            "triage",
+            "triage", "history",
         ])
         // notifiedEventIDsByRepo is optional and nil on the placeholder, so it
         // encodes to nothing rather than a null.
@@ -92,6 +92,7 @@ struct PersistedStateTests {
         #expect(state.sortOrder == .severity)
         #expect(state.notifiedEventIDsByRepo == nil)
         #expect(state.triage == AlertTriage())
+        #expect(state.history == AlertHistory())
     }
 
     @Test func rejectsStateMissingARequiredField() {