krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

1f58092dcb67cded75a850db364f3b46d86181f3

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-22T02:53:16Z

feat(v2.2.0): add foreground monitoring, notifications, and smarter diffs
- implement “Check All” sweep for tracked domains
- add local notifications for changes and certificate expiration
- introduce change severity filtering (low/medium/high)
- enhance change summaries and diff readability
- add certificate expiration tracking and alerts
- improve watchlist indicators for changes
- add sweep summary screen
- optimize performance for larger watchlists
 DomainDig.xcodeproj/project.pbxproj      |  12 +-
 DomainDig/BatchResultsView.swift         |  11 +-
 DomainDig/BatchSweepSummaryView.swift    |  68 ++++
 DomainDig/ContentView.swift              | 115 ++++--
 DomainDig/DomainDiffService.swift        | 337 +++++++++++++---
 DomainDig/DomainDigApp.swift             |   4 +
 DomainDig/DomainViewModel.swift          | 658 +++++++++++++++++++++++++------
 DomainDig/LocalNotificationService.swift |  89 +++++
 DomainDig/Models.swift                   | 111 +++++-
 DomainDig/SSLCheckService.swift          |  73 +++-
 DomainDig/WatchlistView.swift            |  68 +++-
 11 files changed, 1310 insertions(+), 236 deletions(-)

diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 8958217..8e652f6 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -267,7 +267,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 14;
+				CURRENT_PROJECT_VERSION = 15;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -279,12 +279,12 @@
 				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
 				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
 				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
-				IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.6;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.1.0;
+				MARKETING_VERSION = 2.2.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -303,7 +303,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 14;
+				CURRENT_PROJECT_VERSION = 15;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -315,12 +315,12 @@
 				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
 				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
 				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
-				IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.6;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.1.0;
+				MARKETING_VERSION = 2.2.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig/BatchResultsView.swift b/DomainDig/BatchResultsView.swift
index e741830..03bc7c4 100644
--- a/DomainDig/BatchResultsView.swift
+++ b/DomainDig/BatchResultsView.swift
@@ -75,6 +75,12 @@ struct BatchResultRowView: View {
             .font(.system(.caption2, design: .monospaced))
             .foregroundStyle(.secondary)
 
+            if let summaryMessage = result.summaryMessage {
+                Text(summaryMessage)
+                    .font(.system(.caption2, design: .monospaced))
+                    .foregroundStyle(.secondary)
+            }
+
             if let errorMessage = result.errorMessage {
                 Text(errorMessage)
                     .font(.system(.caption2, design: .monospaced))
@@ -103,7 +109,10 @@ struct BatchResultRowView: View {
         case .running:
             return .cyan
         case .completed:
-            if result.quickStatus == "Changed" {
+            if result.changeSeverity == .high || result.certificateWarningLevel == .critical {
+                return .red
+            }
+            if result.changeSeverity == .medium || result.certificateWarningLevel == .warning {
                 return .yellow
             }
             return .green
diff --git a/DomainDig/BatchSweepSummaryView.swift b/DomainDig/BatchSweepSummaryView.swift
new file mode 100644
index 0000000..fda2bc5
--- /dev/null
+++ b/DomainDig/BatchSweepSummaryView.swift
@@ -0,0 +1,68 @@
+import SwiftUI
+
+struct BatchSweepSummaryView: View {
+    @Bindable var viewModel: DomainViewModel
+    let summary: BatchSweepSummary
+
+    @State private var showUnchangedDomains = false
+
+    private var visibleResults: [BatchLookupResult] {
+        if showUnchangedDomains {
+            return summary.results
+        }
+
+        return summary.results.filter {
+            ($0.changeSeverity ?? .low) >= .medium || $0.certificateWarningLevel != .none || $0.status == .failed
+        }
+    }
+
+    var body: some View {
+        NavigationStack {
+            List {
+                Section("Overview") {
+                    statRow(label: "Checked", value: "\(summary.totalDomains)")
+                    statRow(label: "Changed", value: "\(summary.changedDomains)")
+                    statRow(label: "Warnings", value: "\(summary.warningDomains)")
+                    statRow(label: "Unchanged", value: "\(summary.unchangedDomains)")
+                }
+
+                Section {
+                    Toggle("Show unchanged domains", isOn: $showUnchangedDomains)
+                }
+
+                Section(visibleResults.isEmpty ? "Changed Domains" : "Results") {
+                    if visibleResults.isEmpty {
+                        Text("No domains with changes or warnings")
+                            .font(.system(.caption, design: .monospaced))
+                            .foregroundStyle(.secondary)
+                    } else {
+                        ForEach(visibleResults) { result in
+                            if let entry = viewModel.historyEntry(for: result) {
+                                NavigationLink {
+                                    HistoryDetailView(viewModel: viewModel, entry: entry)
+                                } label: {
+                                    BatchResultRowView(result: result)
+                                }
+                            } else {
+                                BatchResultRowView(result: result)
+                            }
+                        }
+                    }
+                }
+            }
+            .navigationTitle(summary.source == .watchlistRefresh ? "Sweep Summary" : "Batch Summary")
+        }
+    }
+
+    private func statRow(label: String, value: String) -> some View {
+        HStack {
+            Text(label)
+                .font(.system(.caption, design: .monospaced))
+                .foregroundStyle(.secondary)
+            Spacer()
+            Text(value)
+                .font(.system(.callout, design: .monospaced))
+                .foregroundStyle(.primary)
+        }
+    }
+}
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index cda9f9e..2be81b3 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -308,6 +308,13 @@ struct ContentView: View {
         VStack(alignment: .leading, spacing: 12) {
             HStack {
                 Spacer()
+                if viewModel.batchLookupRunning {
+                    Button("Cancel") {
+                        viewModel.cancelBatchLookup()
+                    }
+                    .buttonStyle(.bordered)
+                    .font(.system(.caption, design: .monospaced))
+                }
                 if !viewModel.currentBatchResultEntries.isEmpty {
                     Menu {
                         Button("Export Batch TXT") {
@@ -455,45 +462,82 @@ struct DomainChangeSummaryView: View {
     var body: some View {
         CardView(allowsHorizontalScroll: false) {
             HStack {
-                Label(summary.hasChanges ? "Changed" : "Unchanged", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
+                Label(summary.hasChanges ? "Changed" : "Stable", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
                     .font(.system(.caption, design: .monospaced))
-                    .foregroundStyle(summary.hasChanges ? .yellow : .green)
+                    .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .green)
                 Spacer()
+                Text(summary.severity.title.uppercased())
+                    .font(.system(.caption2, design: .monospaced))
+                    .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .secondary)
+                    .padding(.horizontal, 8)
+                    .padding(.vertical, 4)
+                    .background((summary.hasChanges ? severityColor(summary.severity) : .secondary).opacity(0.16))
+                    .clipShape(Capsule())
                 Text(summary.generatedAt, style: .time)
                     .font(.system(.caption2, design: .monospaced))
                     .foregroundStyle(.secondary)
             }
 
-            Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • "))
+            Text(summary.message)
                 .font(.system(.caption, design: .monospaced))
                 .foregroundStyle(.primary)
         }
     }
+
+    private func severityColor(_ severity: ChangeSeverity) -> Color {
+        switch severity {
+        case .low:
+            return .secondary
+        case .medium:
+            return .yellow
+        case .high:
+            return .red
+        }
+    }
 }
 
 struct DomainDiffView: View {
     let title: String
     let sections: [DomainDiffSection]
     let showsUnchanged: Bool
+
     @State private var collapsedSections = Set<UUID>()
+    @State private var showsLowSeverity = false
 
     private var filteredSections: [DomainDiffSection] {
-        guard showsUnchanged else {
-            return sections
-                .map { section in
-                    DomainDiffSection(
-                        title: section.title,
-                        items: section.items.filter(\.hasChanges)
-                    )
+        sections
+            .map { section in
+                let items = section.items.filter { item in
+                    if !showsUnchanged, !item.hasChanges {
+                        return false
+                    }
+                    if showsLowSeverity {
+                        return true
+                    }
+                    return item.severity >= .medium || (showsUnchanged && item.changeType == .unchanged)
                 }
-                .filter { !$0.items.isEmpty }
-        }
-        return sections
+                return DomainDiffSection(title: section.title, items: items)
+            }
+            .filter { !$0.items.isEmpty }
+    }
+
+    private var hasLowSeverityChanges: Bool {
+        sections.flatMap(\.items).contains { $0.hasChanges && $0.severity == .low }
     }
 
     var body: some View {
         VStack(alignment: .leading, spacing: 12) {
-            SectionTitleView(title: title)
+            HStack {
+                SectionTitleView(title: title)
+                Spacer()
+                if hasLowSeverityChanges {
+                    Button(showsLowSeverity ? "Hide Low" : "Show Low") {
+                        showsLowSeverity.toggle()
+                    }
+                    .buttonStyle(.bordered)
+                    .font(.system(.caption, design: .monospaced))
+                }
+            }
             if filteredSections.isEmpty {
                 MessageCardView(text: "No comparison data available", isError: false)
             } else {
@@ -509,12 +553,12 @@ struct DomainDiffView: View {
                                             .font(.system(.caption, design: .monospaced))
                                             .foregroundStyle(.secondary)
                                         Spacer()
-                                        Text(changeLabel(for: item.changeType))
+                                        Text("\(item.severity.title) • \(changeLabel(for: item.changeType))")
                                             .font(.system(.caption2, design: .monospaced))
-                                            .foregroundStyle(changeColor(for: item.changeType))
+                                            .foregroundStyle(changeColor(for: item))
                                             .padding(.horizontal, 8)
                                             .padding(.vertical, 4)
-                                            .background(changeColor(for: item.changeType).opacity(0.16))
+                                            .background(changeColor(for: item).opacity(0.16))
                                             .clipShape(Capsule())
                                     }
 
@@ -543,7 +587,7 @@ struct DomainDiffView: View {
                                     }
                                 }
                                 .padding(10)
-                                .background(item.hasChanges ? changeColor(for: item.changeType).opacity(0.08) : Color(.systemGray6).opacity(0.25))
+                                .background(item.hasChanges ? changeColor(for: item).opacity(0.08) : Color(.systemGray6).opacity(0.25))
                                 .cornerRadius(8)
                             }
                         } label: {
@@ -551,11 +595,11 @@ struct DomainDiffView: View {
                                 Text(section.title)
                                     .font(.system(.subheadline, design: .monospaced))
                                     .fontWeight(.semibold)
-                                    .foregroundStyle(.cyan)
+                                    .foregroundStyle(sectionColor(section))
                                 Spacer()
-                                Text(section.hasChanges ? "Changed" : "Unchanged")
+                                Text(section.severity.title)
                                     .font(.system(.caption2, design: .monospaced))
-                                    .foregroundStyle(section.hasChanges ? .yellow : .secondary)
+                                    .foregroundStyle(sectionColor(section))
                             }
                         }
                     }
@@ -577,16 +621,29 @@ struct DomainDiffView: View {
         }
     }
 
-    private func changeColor(for changeType: DiffChangeType) -> Color {
-        switch changeType {
-        case .added:
-            return .green
-        case .removed:
+    private func changeColor(for item: DomainDiffItem) -> Color {
+        if item.changeType == .unchanged {
+            return .secondary
+        }
+
+        switch item.severity {
+        case .low:
+            return .blue
+        case .medium:
+            return .yellow
+        case .high:
             return .red
-        case .changed:
+        }
+    }
+
+    private func sectionColor(_ section: DomainDiffSection) -> Color {
+        switch section.severity {
+        case .low:
+            return .blue
+        case .medium:
             return .yellow
-        case .unchanged:
-            return .secondary
+        case .high:
+            return .red
         }
     }
 
diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift
index 65ea5a7..667802a 100644
--- a/DomainDig/DomainDiffService.swift
+++ b/DomainDig/DomainDiffService.swift
@@ -13,10 +13,15 @@ struct DomainDiffItem: Identifiable, Equatable {
     let changeType: DiffChangeType
     let oldValue: String?
     let newValue: String?
+    let severity: ChangeSeverity
 
     var hasChanges: Bool {
         changeType != .unchanged
     }
+
+    var isMeaningful: Bool {
+        hasChanges && severity >= .medium
+    }
 }
 
 struct DomainDiffSection: Identifiable, Equatable {
@@ -27,27 +32,19 @@ struct DomainDiffSection: Identifiable, Equatable {
     var hasChanges: Bool {
         items.contains(where: \.hasChanges)
     }
+
+    var severity: ChangeSeverity {
+        items.map(\.severity).max() ?? .low
+    }
 }
 
 enum DomainDiffService {
     static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] {
         [
-            section(title: "Availability", item: compare(
-                label: "Status",
-                oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status),
-                newValue: availabilityLabel(newSnapshot.availabilityResult?.status)
-            )),
-            section(title: "Primary IP", item: compare(
-                label: "Address",
-                oldValue: primaryIP(from: oldSnapshot),
-                newValue: primaryIP(from: newSnapshot)
-            )),
+            availabilitySection(from: oldSnapshot, to: newSnapshot),
+            primaryIPSection(from: oldSnapshot, to: newSnapshot),
             dnsSection(from: oldSnapshot, to: newSnapshot),
-            section(title: "Redirect", item: compare(
-                label: "Final Target",
-                oldValue: finalRedirectURL(from: oldSnapshot),
-                newValue: finalRedirectURL(from: newSnapshot)
-            )),
+            redirectSection(from: oldSnapshot, to: newSnapshot),
             tlsSection(from: oldSnapshot, to: newSnapshot),
             httpSection(from: oldSnapshot, to: newSnapshot),
             emailSection(from: oldSnapshot, to: newSnapshot)
@@ -55,62 +52,212 @@ enum DomainDiffService {
         .filter { !$0.items.isEmpty }
     }
 
-    static func summary(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot, generatedAt: Date = Date()) -> DomainChangeSummary {
-        let changedSections = diff(from: oldSnapshot, to: newSnapshot)
-            .filter { $0.items.contains(where: { $0.changeType != .unchanged }) }
-            .map(\.title)
+    static func summary(
+        from oldSnapshot: LookupSnapshot,
+        to newSnapshot: LookupSnapshot,
+        generatedAt: Date = Date()
+    ) -> DomainChangeSummary {
+        let sections = diff(from: oldSnapshot, to: newSnapshot)
+        let meaningfulItems = sections
+            .flatMap(\.items)
+            .filter(\.isMeaningful)
+        let allChangedItems = sections
+            .flatMap(\.items)
+            .filter(\.hasChanges)
+
+        let highlights = summaryHighlights(from: meaningfulItems)
+        let severity = meaningfulItems.map(\.severity).max() ?? (allChangedItems.isEmpty ? .low : .low)
+        let message = summaryMessage(from: meaningfulItems, highlights: highlights)
 
         return DomainChangeSummary(
-            hasChanges: !changedSections.isEmpty,
-            changedSections: changedSections,
+            hasChanges: !meaningfulItems.isEmpty,
+            changedSections: highlights,
+            message: message,
+            severity: severity,
             generatedAt: generatedAt
         )
     }
 
-    private static func section(title: String, item: DomainDiffItem?) -> DomainDiffSection {
-        DomainDiffSection(title: title, items: item.map { [$0] } ?? [])
+    static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
+        guard let days = snapshot.sslInfo?.daysUntilExpiry else {
+            return .none
+        }
+        if days < 14 {
+            return .critical
+        }
+        if days < 30 {
+            return .warning
+        }
+        return .none
+    }
+
+    private static func availabilitySection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+        DomainDiffSection(
+            title: "Availability",
+            items: [
+                compare(
+                    label: "Availability",
+                    oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status),
+                    newValue: availabilityLabel(newSnapshot.availabilityResult?.status),
+                    severity: .high
+                )
+            ].compactMap { $0 }
+        )
+    }
+
+    private static func primaryIPSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+        DomainDiffSection(
+            title: "Primary IP",
+            items: [
+                compare(
+                    label: "Primary IP",
+                    oldValue: primaryIP(from: oldSnapshot),
+                    newValue: primaryIP(from: newSnapshot),
+                    severity: .high
+                )
+            ].compactMap { $0 }
+        )
     }
 
     private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
-        let oldValue = normalizedDNSSummary(from: oldSnapshot)
-        let newValue = normalizedDNSSummary(from: newSnapshot)
-        return section(title: "DNS Records", item: compare(label: "Records", oldValue: oldValue, newValue: newValue))
+        let oldSections = Dictionary(uniqueKeysWithValues: oldSnapshot.dnsSections.map { ($0.recordType, $0) })
+        let newSections = Dictionary(uniqueKeysWithValues: newSnapshot.dnsSections.map { ($0.recordType, $0) })
+        let types = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue }
+
+        var items: [DomainDiffItem] = []
+        for type in types {
+            let oldSection = oldSections[type]
+            let newSection = newSections[type]
+
+            if let recordChange = compare(
+                label: "\(type.rawValue) Records",
+                oldValue: normalizedRecordValues(for: oldSection),
+                newValue: normalizedRecordValues(for: newSection),
+                severity: .medium
+            ) {
+                items.append(recordChange)
+            }
+
+            if let ttlChange = compare(
+                label: "\(type.rawValue) TTL",
+                oldValue: normalizedTTLValues(for: oldSection),
+                newValue: normalizedTTLValues(for: newSection),
+                severity: .low
+            ), let oldSection, let newSection,
+               normalizedRecordValues(for: oldSection) == normalizedRecordValues(for: newSection) {
+                items.append(ttlChange)
+            }
+        }
+
+        return DomainDiffSection(title: "DNS", items: items)
+    }
+
+    private static func redirectSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+        DomainDiffSection(
+            title: "Redirect",
+            items: [
+                compare(
+                    label: "Redirect Target",
+                    oldValue: finalRedirectURL(from: oldSnapshot),
+                    newValue: finalRedirectURL(from: newSnapshot),
+                    severity: .high
+                )
+            ].compactMap { $0 }
+        )
     }
 
     private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
         var items: [DomainDiffItem] = []
-        if let item = compare(label: "Issuer", oldValue: normalized(oldSnapshot.sslInfo?.issuer), newValue: normalized(newSnapshot.sslInfo?.issuer)) {
-            items.append(item)
+
+        if let issuerChange = compare(
+            label: "TLS Issuer",
+            oldValue: normalized(oldSnapshot.sslInfo?.issuer),
+            newValue: normalized(newSnapshot.sslInfo?.issuer),
+            severity: .medium
+        ) {
+            items.append(issuerChange)
+        }
+
+        if let expiryChange = compare(
+            label: "TLS Expiration",
+            oldValue: expirationLabel(oldSnapshot.sslInfo),
+            newValue: expirationLabel(newSnapshot.sslInfo),
+            severity: .medium
+        ) {
+            items.append(expiryChange)
         }
-        if let item = compare(label: "Certificate", oldValue: tlsSummary(from: oldSnapshot), newValue: tlsSummary(from: newSnapshot)) {
-            items.append(item)
+
+        let oldWarning = certificateWarningLevel(for: oldSnapshot)
+        let newWarning = certificateWarningLevel(for: newSnapshot)
+        if oldWarning != newWarning, newWarning != .none {
+            let days = newSnapshot.sslInfo?.daysUntilExpiry ?? 0
+            items.append(
+                DomainDiffItem(
+                    label: "Certificate Warning",
+                    changeType: .changed,
+                    oldValue: oldWarning.title,
+                    newValue: "Certificate expires in \(days) days",
+                    severity: newWarning == .critical ? .high : .medium
+                )
+            )
         }
-        return DomainDiffSection(title: "TLS Certificate", items: items)
+
+        return DomainDiffSection(title: "TLS", items: items)
     }
 
     private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
         var items: [DomainDiffItem] = []
-        if let item = compare(label: "HTTP Status", oldValue: httpStatusSummary(from: oldSnapshot), newValue: httpStatusSummary(from: newSnapshot)) {
-            items.append(item)
+
+        if let statusChange = compare(
+            label: "HTTP Status",
+            oldValue: httpStatusSummary(from: oldSnapshot),
+            newValue: httpStatusSummary(from: newSnapshot),
+            severity: .medium
+        ) {
+            items.append(statusChange)
         }
-        if let item = compare(label: "Security Grade", oldValue: normalized(oldSnapshot.httpSecurityGrade), newValue: normalized(newSnapshot.httpSecurityGrade)) {
-            items.append(item)
+
+        if let gradeChange = compare(
+            label: "Security Grade",
+            oldValue: normalized(oldSnapshot.httpSecurityGrade),
+            newValue: normalized(newSnapshot.httpSecurityGrade),
+            severity: .low
+        ) {
+            items.append(gradeChange)
+        }
+
+        if let headerChange = compare(
+            label: "Headers",
+            oldValue: normalizedHeaders(from: oldSnapshot),
+            newValue: normalizedHeaders(from: newSnapshot),
+            severity: .low
+        ) {
+            items.append(headerChange)
         }
+
         return DomainDiffSection(title: "HTTP", items: items)
     }
 
     private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
-        section(
+        DomainDiffSection(
             title: "Email Security",
-            item: compare(
-                label: "Summary",
-                oldValue: normalized(emailSummary(from: oldSnapshot)),
-                newValue: normalized(emailSummary(from: newSnapshot))
-            )
+            items: [
+                compare(
+                    label: "Email Security",
+                    oldValue: normalized(emailSummary(from: oldSnapshot)),
+                    newValue: normalized(emailSummary(from: newSnapshot)),
+                    severity: .medium
+                )
+            ].compactMap { $0 }
         )
     }
 
-    private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? {
+    private static func compare(
+        label: String,
+        oldValue: String?,
+        newValue: String?,
+        severity: ChangeSeverity
+    ) -> DomainDiffItem? {
         let oldValue = normalized(oldValue)
         let newValue = normalized(newValue)
         let normalizedOldValue = comparisonValue(oldValue)
@@ -132,7 +279,68 @@ enum DomainDiffService {
             changeType = .changed
         }
 
-        return DomainDiffItem(label: label, changeType: changeType, oldValue: oldValue, newValue: newValue)
+        return DomainDiffItem(
+            label: label,
+            changeType: changeType,
+            oldValue: oldValue,
+            newValue: newValue,
+            severity: severity
+        )
+    }
+
+    private static func summaryHighlights(from items: [DomainDiffItem]) -> [String] {
+        var highlights: [String] = []
+
+        let labels = Set(items.map(\.label))
+        if labels.contains("Availability") {
+            highlights.append("Availability changed")
+        }
+        if labels.contains("Primary IP"), labels.contains(where: { $0.hasSuffix("Records") }) {
+            highlights.append("IP changed")
+            highlights.append("DNS changed")
+            return highlights
+        }
+        if labels.contains("Primary IP") {
+            highlights.append("IP changed")
+        }
+        if labels.contains("Redirect Target") {
+            highlights.append("Redirect target changed")
+        }
+        if let certificateItem = items.first(where: { $0.label == "Certificate Warning" }),
+           let message = certificateItem.newValue {
+            highlights.append(message)
+        } else if labels.contains("TLS Issuer") {
+            highlights.append("TLS issuer changed")
+        } else if labels.contains("TLS Expiration") {
+            highlights.append("Certificate expiration changed")
+        }
+        if labels.contains(where: { $0.hasSuffix("Records") }) {
+            highlights.append("DNS changed")
+        }
+        if labels.contains("HTTP Status") {
+            highlights.append("HTTP status changed")
+        }
+        if labels.contains("Email Security") {
+            highlights.append("Email security changed")
+        }
+
+        var deduplicated: [String] = []
+        for highlight in highlights where !deduplicated.contains(highlight) {
+            deduplicated.append(highlight)
+        }
+        return deduplicated
+    }
+
+    private static func summaryMessage(from items: [DomainDiffItem], highlights: [String]) -> String {
+        guard !items.isEmpty, !highlights.isEmpty else {
+            return "No meaningful changes"
+        }
+
+        if highlights.count == 1 {
+            return highlights[0]
+        }
+
+        return "\(highlights[0]) and \(highlights[1].lowercased())"
     }
 
     private static func normalized(_ value: String?) -> String? {
@@ -167,11 +375,9 @@ enum DomainDiffService {
         snapshot.redirectChain.last?.url
     }
 
-    private static func tlsSummary(from snapshot: LookupSnapshot) -> String? {
-        if let sslInfo = snapshot.sslInfo {
-            return "\(sslInfo.commonName) | \(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted))"
-        }
-        return snapshot.sslError
+    private static func expirationLabel(_ sslInfo: SSLCertificateInfo?) -> String? {
+        guard let sslInfo else { return nil }
+        return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)"
     }
 
     private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? {
@@ -194,18 +400,27 @@ enum DomainDiffService {
         return snapshot.emailSecurityError
     }
 
-    private static func normalizedDNSSummary(from snapshot: LookupSnapshot) -> String? {
-        let parts = snapshot.dnsSections
-            .sorted { $0.recordType.rawValue < $1.recordType.rawValue }
-            .map { section in
-                let values = (section.records + section.wildcardRecords)
-                    .map(\.value)
-                    .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
-                    .sorted()
-                    .joined(separator: ",")
-                return "\(section.recordType.rawValue):\(values)"
-            }
-            .filter { !$0.hasSuffix(":") }
-        return parts.isEmpty ? nil : parts.joined(separator: "|")
+    private static func normalizedRecordValues(for section: DNSSection?) -> String? {
+        guard let section else { return nil }
+        let values = (section.records + section.wildcardRecords)
+            .map(\.value)
+            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
+            .sorted()
+        return values.isEmpty ? nil : values.joined(separator: ",")
+    }
+
+    private static func normalizedTTLValues(for section: DNSSection?) -> String? {
+        guard let section else { return nil }
+        let values = (section.records + section.wildcardRecords)
+            .map { "\($0.value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()):\($0.ttl)" }
+            .sorted()
+        return values.isEmpty ? nil : values.joined(separator: ",")
+    }
+
+    private static func normalizedHeaders(from snapshot: LookupSnapshot) -> String? {
+        let headers = snapshot.httpHeaders
+            .map { "\($0.name.lowercased()):\($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" }
+            .sorted()
+        return headers.isEmpty ? nil : headers.joined(separator: "|")
     }
 }
diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift
index 9c881d8..a34eb12 100644
--- a/DomainDig/DomainDigApp.swift
+++ b/DomainDig/DomainDigApp.swift
@@ -9,6 +9,10 @@ import SwiftUI
 
 @main
 struct DomainDigApp: App {
+    init() {
+        LocalNotificationService.shared.configureForegroundPresentation()
+    }
+
     var body: some Scene {
         WindowGroup {
             ContentView()
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 82ec540..031c062 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -159,6 +159,10 @@ extension HistoryEntry {
     }
 }
 
+private struct BatchLookupPayload {
+    let snapshot: LookupSnapshot
+}
+
 @MainActor
 @Observable
 final class DomainViewModel {
@@ -228,11 +232,16 @@ final class DomainViewModel {
     private(set) var batchCompletedCount = 0
     private(set) var batchTotalCount = 0
     private(set) var batchLookupRunning = false
+    var latestBatchSweepSummary: BatchSweepSummary?
+    private(set) var notificationsAuthorized = false
 
     private var lookupTask: Task<Void, Never>?
     private var customPortScanTask: Task<Void, Never>?
+    private var batchTask: Task<Void, Never>?
     private var activeLookupID = UUID()
     private var lookupStartedAt: Date?
+    private var activeBatchDomains: [String] = []
+    private var lastBatchStartedAt: Date?
 
     private static let recentSearchesKey = "recentSearches"
     private static let maxRecent = 20
@@ -355,8 +364,8 @@ final class DomainViewModel {
 
     var batchProgressLabel: String {
         guard batchTotalCount > 0 else { return "No active batch" }
-        let domainLabel = batchCurrentDomain ?? "Preparing"
-        return "\(batchCompletedCount + (batchLookupRunning ? 1 : 0))/\(batchTotalCount) • \(domainLabel)"
+        let domainLabel = activeBatchDomains.first ?? batchCurrentDomain ?? "Preparing"
+        return "\(batchCompletedCount)/\(batchTotalCount) • \(domainLabel)"
     }
 
     var currentBatchResultEntries: [HistoryEntry] {
@@ -550,6 +559,10 @@ final class DomainViewModel {
     func refreshTrackedDomain(_ trackedDomain: TrackedDomain) {
         refreshingTrackedDomainID = trackedDomain.id
         domain = trackedDomain.domain
+        Task { [weak self] in
+            guard let self else { return }
+            self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
+        }
         run()
     }
 
@@ -624,6 +637,7 @@ final class DomainViewModel {
     func reset() {
         lookupTask?.cancel()
         customPortScanTask?.cancel()
+        batchTask?.cancel()
         hasRun = false
         searchedDomain = ""
         lastLookupDurationMs = nil
@@ -649,59 +663,36 @@ final class DomainViewModel {
     func runBulkLookup() {
         let domains = parsedDomains(from: bulkInput)
         guard !domains.isEmpty else { return }
-
-        clearBatchState()
-        batchLookupSource = .manual
-        batchTotalCount = domains.count
-        batchLookupRunning = true
-        batchResults = domains.map {
-            BatchLookupResult(
-                domain: $0,
-                historyEntryID: nil,
-                availability: nil,
-                primaryIP: nil,
-                quickStatus: "Pending",
-                timestamp: Date(),
-                status: .pending
-            )
-        }
-
-        lookupTask?.cancel()
-        customPortScanTask?.cancel()
-
-        lookupTask = Task { [weak self] in
-            guard let self else { return }
-            await self.runBatchLookup(domains: domains, source: .manual)
-        }
+        startBatchLookup(domains: domains, source: .manual)
     }
 
     func refreshAllTrackedDomains() {
-        let domains = sortedTrackedDomains.map(\.domain)
-        guard !domains.isEmpty else { return }
+        startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh)
+    }
 
-        clearBatchState()
-        batchLookupSource = .watchlistRefresh
-        batchTotalCount = domains.count
-        batchLookupRunning = true
-        batchResults = domains.map {
-            BatchLookupResult(
-                domain: $0,
-                historyEntryID: nil,
-                availability: nil,
-                primaryIP: nil,
-                quickStatus: "Pending",
+    func cancelBatchLookup() {
+        batchTask?.cancel()
+        batchLookupRunning = false
+        batchCurrentDomain = nil
+        activeBatchDomains = []
+        refreshingTrackedDomainID = nil
+
+        for index in batchResults.indices where batchResults[index].status == .pending || batchResults[index].status == .running {
+            batchResults[index] = BatchLookupResult(
+                id: batchResults[index].id,
+                domain: batchResults[index].domain,
+                historyEntryID: batchResults[index].historyEntryID,
+                availability: batchResults[index].availability,
+                primaryIP: batchResults[index].primaryIP,
+                quickStatus: "Cancelled",
+                summaryMessage: batchResults[index].summaryMessage,
+                changeSeverity: batchResults[index].changeSeverity,
+                certificateWarningLevel: batchResults[index].certificateWarningLevel,
                 timestamp: Date(),
-                status: .pending
+                status: .failed,
+                errorMessage: "Lookup cancelled"
             )
         }
-
-        lookupTask?.cancel()
-        customPortScanTask?.cancel()
-
-        lookupTask = Task { [weak self] in
-            guard let self else { return }
-            await self.runBatchLookup(domains: domains, source: .watchlistRefresh)
-        }
     }
 
     func runCustomPortScan(ports: [UInt16]) async {
@@ -1057,7 +1048,215 @@ final class DomainViewModel {
         customPortScanLoading = false
     }
 
-    private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] {
+    private static func performBatchLookup(domain: String) async -> BatchLookupPayload? {
+        guard !Task.isCancelled else { return nil }
+
+        let startedAt = Date()
+        let resolverDisplayName = DNSLookupService.currentResolverDisplayName()
+        let resolverURLString = DNSLookupService.currentResolverURLString()
+
+        async let dnsResult = DNSLookupService.lookupAll(domain: domain)
+        async let availabilityResult = DomainAvailabilityService.check(domain: domain)
+        async let sslResult = SSLCheckService.check(domain: domain)
+        async let hstsResult = SSLCheckService.checkHSTSPreload(domain: domain)
+        async let httpResult = HTTPHeadersService.fetch(domain: domain)
+        async let reachabilityResult = ReachabilityService.checkAll(domain: domain)
+        async let redirectResult = RedirectChainService.trace(domain: domain)
+        async let portScanResult = PortScanService.scanAll(domain: domain)
+
+        let resolvedDNS = await dnsResult
+        let availability = await availabilityResult
+        let resolvedSSL = await sslResult
+        let hsts = await hstsResult
+        let http = await httpResult
+        let reachability = await reachabilityResult
+        let redirects = await redirectResult
+        let ports = await portScanResult
+
+        guard !Task.isCancelled else { return nil }
+
+        let dnsSections: [DNSSection]
+        let dnsError: String?
+        switch resolvedDNS {
+        case let .success(sections):
+            dnsSections = sections
+            dnsError = nil
+        case let .empty(message), let .error(message):
+            dnsSections = []
+            dnsError = message
+        }
+
+        let sslInfo: SSLCertificateInfo?
+        let sslError: String?
+        switch resolvedSSL {
+        case let .success(info):
+            sslInfo = info
+            sslError = nil
+        case let .empty(message), let .error(message):
+            sslInfo = nil
+            sslError = message
+        }
+
+        let httpHeaders: [HTTPHeader]
+        let httpSecurityGrade: String?
+        let httpStatusCode: Int?
+        let httpResponseTimeMs: Int?
+        let httpProtocol: String?
+        let http3Advertised: Bool
+        let httpHeadersError: String?
+        switch http {
+        case let .success(result):
+            httpHeaders = result.headers
+            httpSecurityGrade = HTTPSecurityGrade.grade(for: result.headers).rawValue
+            httpStatusCode = result.statusCode
+            httpResponseTimeMs = result.responseTimeMs
+            httpProtocol = result.httpProtocol
+            http3Advertised = result.http3Advertised
+            httpHeadersError = nil
+        case let .empty(message), let .error(message):
+            httpHeaders = []
+            httpSecurityGrade = nil
+            httpStatusCode = nil
+            httpResponseTimeMs = nil
+            httpProtocol = nil
+            http3Advertised = false
+            httpHeadersError = message
+        }
+
+        let reachabilityResults: [PortReachability]
+        let reachabilityError: String?
+        switch reachability {
+        case let .success(results):
+            reachabilityResults = results
+            reachabilityError = nil
+        case let .empty(message), let .error(message):
+            reachabilityResults = []
+            reachabilityError = message
+        }
+
+        let redirectChain: [RedirectHop]
+        let redirectChainError: String?
+        switch redirects {
+        case let .success(hops):
+            redirectChain = hops
+            redirectChainError = nil
+        case let .empty(message), let .error(message):
+            redirectChain = []
+            redirectChainError = message
+        }
+
+        let portScanResults: [PortScanResult]
+        let portScanError: String?
+        switch ports {
+        case let .success(results):
+            portScanResults = await enrichOpenPortBanners(results, domain: domain)
+            portScanError = nil
+        case let .empty(message), let .error(message):
+            portScanResults = []
+            portScanError = message
+        }
+
+        let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
+        let primaryIP = dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
+
+        async let emailResult = EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)
+        async let suggestions = availability.status == .registered ? DomainAvailabilityService.suggestions(for: domain) : []
+
+        let resolvedEmail = await emailResult
+        let resolvedSuggestions = await suggestions
+        let resolvedPTR: ServiceResult<String>?
+        let resolvedGeo: ServiceResult<IPGeolocation>?
+        if let primaryIP {
+            resolvedPTR = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString)
+            resolvedGeo = await IPGeolocationService.lookup(ip: primaryIP)
+        } else {
+            resolvedPTR = nil
+            resolvedGeo = nil
+        }
+
+        guard !Task.isCancelled else { return nil }
+
+        let emailSecurity: EmailSecurityResult?
+        let emailSecurityError: String?
+        switch resolvedEmail {
+        case let .success(result):
+            emailSecurity = result
+            emailSecurityError = nil
+        case let .empty(message), let .error(message):
+            emailSecurity = nil
+            emailSecurityError = message
+        }
+
+        let ptrRecord: String?
+        let ptrError: String?
+        switch resolvedPTR {
+        case let .success(record):
+            ptrRecord = record
+            ptrError = nil
+        case let .empty(message), let .error(message):
+            ptrRecord = nil
+            ptrError = message
+        case .none:
+            ptrRecord = nil
+            ptrError = "No A record available"
+        }
+
+        let ipGeolocation: IPGeolocation?
+        let ipGeolocationError: String?
+        switch resolvedGeo {
+        case let .success(result):
+            ipGeolocation = result
+            ipGeolocationError = nil
+        case let .empty(message), let .error(message):
+            ipGeolocation = nil
+            ipGeolocationError = message
+        case .none:
+            ipGeolocation = nil
+            ipGeolocationError = "No A record available"
+        }
+
+        let snapshot = LookupSnapshot(
+            historyEntryID: nil,
+            domain: domain,
+            timestamp: Date(),
+            trackedDomainID: nil,
+            resolverDisplayName: resolverDisplayName,
+            resolverURLString: resolverURLString,
+            totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000),
+            dnsSections: dnsSections,
+            dnsError: dnsError,
+            availabilityResult: availability,
+            suggestions: resolvedSuggestions,
+            sslInfo: sslInfo,
+            sslError: sslError,
+            hstsPreloaded: hsts,
+            httpHeaders: httpHeaders,
+            httpSecurityGrade: httpSecurityGrade,
+            httpStatusCode: httpStatusCode,
+            httpResponseTimeMs: httpResponseTimeMs,
+            httpProtocol: httpProtocol,
+            http3Advertised: http3Advertised,
+            httpHeadersError: httpHeadersError,
+            reachabilityResults: reachabilityResults,
+            reachabilityError: reachabilityError,
+            ipGeolocation: ipGeolocation,
+            ipGeolocationError: ipGeolocationError,
+            emailSecurity: emailSecurity,
+            emailSecurityError: emailSecurityError,
+            ptrRecord: ptrRecord,
+            ptrError: ptrError,
+            redirectChain: redirectChain,
+            redirectChainError: redirectChainError,
+            portScanResults: portScanResults,
+            portScanError: portScanError,
+            changeSummary: nil,
+            isLive: false
+        )
+
+        return BatchLookupPayload(snapshot: snapshot)
+    }
+
+    private static func enrichOpenPortBanners(_ results: [PortScanResult], domain: String) async -> [PortScanResult] {
         let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in
             for result in results where result.open {
                 group.addTask {
@@ -1082,56 +1281,67 @@ final class DomainViewModel {
         }
     }
 
+    private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] {
+        await Self.enrichOpenPortBanners(results, domain: domain)
+    }
+
     @discardableResult
     private func saveHistoryEntry(replaceLatest: Bool) -> HistoryEntry? {
         guard !searchedDomain.isEmpty else { return nil }
+        return saveHistoryEntry(from: currentSnapshot, replaceLatest: replaceLatest, updateCurrentState: true)
+    }
 
-        let trackedDomainID = trackedDomain(for: searchedDomain)?.id
-        let timestamp = Date()
-        let snapshot = currentSnapshot
-        let previousSnapshot = previousSnapshot(for: searchedDomain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
-        let changeSummary = previousSnapshot.map { DomainDiffService.summary(from: $0, to: snapshot, generatedAt: timestamp) }
+    @discardableResult
+    private func saveHistoryEntry(from snapshot: LookupSnapshot, replaceLatest: Bool, updateCurrentState: Bool) -> HistoryEntry? {
+        let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id
+        let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
+        let changeSummary = previousSnapshot.map {
+            DomainDiffService.summary(from: $0, to: snapshot, generatedAt: snapshot.timestamp)
+        }
+        let diffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []
 
-        currentChangeSummary = changeSummary
-        currentDiffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []
+        if updateCurrentState {
+            currentChangeSummary = changeSummary
+            currentDiffSections = diffSections
+        }
 
         let entry = HistoryEntry(
-            domain: searchedDomain,
-            timestamp: timestamp,
+            domain: snapshot.domain,
+            timestamp: snapshot.timestamp,
             trackedDomainID: trackedDomainID,
-            dnsSections: dnsSections,
-            sslInfo: sslInfo,
-            httpHeaders: httpHeaders,
-            reachabilityResults: reachabilityResults,
-            ipGeolocation: ipGeolocation,
-            emailSecurity: emailSecurity,
-            mtaSts: emailSecurity?.mtaSts,
-            ptrRecord: ptrRecord,
-            redirectChain: redirectChain,
-            portScanResults: allPortScanResults,
-            hstsPreloaded: hstsPreloaded,
-            availabilityResult: availabilityResult,
-            suggestions: suggestions,
-            resolverDisplayName: resolverDisplayName,
-            resolverURLString: resolverURLString,
-            totalLookupDurationMs: lastLookupDurationMs,
+            dnsSections: snapshot.dnsSections,
+            sslInfo: snapshot.sslInfo,
+            httpHeaders: snapshot.httpHeaders,
+            reachabilityResults: snapshot.reachabilityResults,
+            ipGeolocation: snapshot.ipGeolocation,
+            emailSecurity: snapshot.emailSecurity,
+            mtaSts: snapshot.emailSecurity?.mtaSts,
+            ptrRecord: snapshot.ptrRecord,
+            redirectChain: snapshot.redirectChain,
+            portScanResults: snapshot.portScanResults,
+            hstsPreloaded: snapshot.hstsPreloaded,
+            availabilityResult: snapshot.availabilityResult,
+            suggestions: snapshot.suggestions,
+            resolverDisplayName: snapshot.resolverDisplayName,
+            resolverURLString: snapshot.resolverURLString,
+            totalLookupDurationMs: snapshot.totalLookupDurationMs,
             primaryIP: Self.primaryIPAddress(from: snapshot),
             finalRedirectURL: Self.finalRedirectTarget(from: snapshot),
             tlsStatusSummary: Self.httpsSummary(from: snapshot),
             emailSecuritySummary: Self.emailSummary(from: snapshot),
             httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
             changeSummary: changeSummary,
-            sslError: sslError,
-            httpHeadersError: httpHeadersError,
-            reachabilityError: reachabilityError,
-            ipGeolocationError: ipGeolocationError,
-            emailSecurityError: emailSecurityError,
-            ptrError: ptrError,
-            redirectChainError: redirectChainError,
-            portScanError: combinedPortScanError
+            sslError: snapshot.sslError,
+            httpHeadersError: snapshot.httpHeadersError,
+            reachabilityError: snapshot.reachabilityError,
+            ipGeolocationError: snapshot.ipGeolocationError,
+            emailSecurityError: snapshot.emailSecurityError,
+            ptrError: snapshot.ptrError,
+            redirectChainError: snapshot.redirectChainError,
+            portScanError: snapshot.portScanError
         )
 
-        if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(searchedDomain) == .orderedSame {
+        if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame {
             history[0] = entry
         } else {
             history.insert(entry, at: 0)
@@ -1141,13 +1351,17 @@ final class DomainViewModel {
         }
 
         updateTrackedDomainSnapshotMetadata(
-            domain: searchedDomain,
+            domain: snapshot.domain,
             snapshotID: entry.id,
-            availabilityStatus: availabilityResult?.status,
-            updatedAt: timestamp,
-            changeSummary: changeSummary
+            availabilityStatus: snapshot.availabilityResult?.status,
+            updatedAt: snapshot.timestamp,
+            changeSummary: changeSummary,
+            changeSeverity: changeSummary?.severity,
+            certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
+            certificateDaysRemaining: snapshot.sslInfo?.daysUntilExpiry
         )
         persistHistory()
+        notifyIfNeeded(for: entry, snapshot: snapshot, previousSnapshot: previousSnapshot)
         return entry
     }
 
@@ -1176,7 +1390,10 @@ final class DomainViewModel {
         snapshotID: UUID,
         availabilityStatus: DomainAvailabilityStatus?,
         updatedAt: Date,
-        changeSummary: DomainChangeSummary?
+        changeSummary: DomainChangeSummary?,
+        changeSeverity: ChangeSeverity?,
+        certificateWarningLevel: CertificateWarningLevel,
+        certificateDaysRemaining: Int?
     ) {
         guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
             return
@@ -1185,9 +1402,44 @@ final class DomainViewModel {
         trackedDomains[index].lastKnownAvailability = availabilityStatus
         trackedDomains[index].updatedAt = updatedAt
         trackedDomains[index].lastChangeSummary = changeSummary
+        trackedDomains[index].lastChangeSeverity = changeSeverity
+        trackedDomains[index].certificateWarningLevel = certificateWarningLevel
+        trackedDomains[index].certificateDaysRemaining = certificateDaysRemaining
         persistTrackedDomains()
     }
 
+    private func notifyIfNeeded(for entry: HistoryEntry, snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot?) {
+        guard notificationsAuthorized, entry.trackedDomainID != nil else { return }
+
+        Task {
+            if let summary = entry.changeSummary, summary.hasChanges {
+                await LocalNotificationService.shared.notifyDomainEvent(
+                    domain: entry.domain,
+                    message: summary.message,
+                    severity: summary.severity
+                )
+            }
+
+            let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
+            if certificateWarningLevel == .critical, let daysRemaining = snapshot.sslInfo?.daysUntilExpiry {
+                await LocalNotificationService.shared.notifyCertificateWarning(
+                    domain: entry.domain,
+                    daysRemaining: daysRemaining
+                )
+            }
+
+            if let previousStatus = previousSnapshot?.availabilityResult?.status,
+               let newStatus = snapshot.availabilityResult?.status,
+               previousStatus != newStatus {
+                await LocalNotificationService.shared.notifyDomainEvent(
+                    domain: entry.domain,
+                    message: "Availability changed",
+                    severity: .high
+                )
+            }
+        }
+    }
+
     private func previousSnapshot(for domain: String, trackedDomainID: UUID?, replacingLatest: Bool) -> LookupSnapshot? {
         let matchingEntries = history.filter { entry in
             if let trackedDomainID {
@@ -1235,7 +1487,10 @@ final class DomainViewModel {
            let trackedIndex = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
             trackedDomains[trackedIndex].lastSnapshotID = latestEntry.id
             trackedDomains[trackedIndex].lastChangeSummary = latestEntry.changeSummary
+            trackedDomains[trackedIndex].lastChangeSeverity = latestEntry.changeSummary?.severity
             trackedDomains[trackedIndex].lastKnownAvailability = latestEntry.availabilityResult?.status
+            trackedDomains[trackedIndex].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: latestEntry.snapshot)
+            trackedDomains[trackedIndex].certificateDaysRemaining = latestEntry.sslInfo?.daysUntilExpiry
             trackedDomains[trackedIndex].updatedAt = latestEntry.timestamp
             persistTrackedDomains()
         }
@@ -1271,6 +1526,43 @@ final class DomainViewModel {
         return lookupID
     }
 
+    private func startBatchLookup(domains: [String], source: BatchLookupSource) {
+        guard !domains.isEmpty else { return }
+        guard !batchLookupRunning else { return }
+
+        let now = Date()
+        if let lastBatchStartedAt, now.timeIntervalSince(lastBatchStartedAt) < 1 {
+            return
+        }
+
+        lastBatchStartedAt = now
+        clearBatchState()
+        batchLookupSource = source
+        batchTotalCount = domains.count
+        batchLookupRunning = true
+        batchResults = domains.map {
+            BatchLookupResult(
+                domain: $0,
+                historyEntryID: nil,
+                availability: nil,
+                primaryIP: nil,
+                quickStatus: "Pending",
+                timestamp: Date(),
+                status: .pending
+            )
+        }
+
+        lookupTask?.cancel()
+        customPortScanTask?.cancel()
+        batchTask?.cancel()
+
+        batchTask = Task { [weak self] in
+            guard let self else { return }
+            self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
+            await self.runBatchLookup(domains: domains, source: source)
+        }
+    }
+
     private func clearBatchState() {
         batchResults = []
         batchLookupSource = .manual
@@ -1278,6 +1570,9 @@ final class DomainViewModel {
         batchCompletedCount = 0
         batchTotalCount = 0
         batchLookupRunning = false
+        latestBatchSweepSummary = nil
+        activeBatchDomains = []
+        batchTask = nil
     }
 
     private func parsedDomains(from input: String) -> [String] {
@@ -1292,42 +1587,114 @@ final class DomainViewModel {
     }
 
     private func runBatchLookup(domains: [String], source: BatchLookupSource) async {
-        for (index, domain) in domains.enumerated() {
-            guard !Task.isCancelled else { break }
-
-            batchCurrentDomain = domain
-            if source == .watchlistRefresh {
-                refreshingTrackedDomainID = trackedDomain(for: domain)?.id
+        let concurrencyLimit = min(source == .watchlistRefresh ? 4 : 3, max(domains.count, 1))
+        var nextIndex = 0
+
+        await withTaskGroup(of: (String, BatchLookupPayload?).self) { group in
+            for _ in 0..<concurrencyLimit {
+                guard nextIndex < domains.count else { break }
+                let domain = domains[nextIndex]
+                nextIndex += 1
+                enqueueBatchLookup(domain: domain, source: source, group: &group)
             }
-            updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil)
-
-            let lookupID = beginLookup(for: domain, cancelExistingTask: false)
-            let entry = await performLookup(domain: domain, lookupID: lookupID)
-
-            if let entry {
-                updateBatchResult(
-                    domain: domain,
-                    status: .completed,
-                    quickStatus: entry.changeSummary?.hasChanges == true ? "Changed" : "Unchanged",
-                    entry: entry,
-                    errorMessage: nil
-                )
-            } else {
-                updateBatchResult(
-                    domain: domain,
-                    status: .failed,
-                    quickStatus: "Failed",
-                    entry: nil,
-                    errorMessage: "Lookup cancelled"
-                )
+
+            while let (domain, payload) = await group.next() {
+                completeBatchLookup(domain: domain, payload: payload)
+
+                if nextIndex < domains.count, !Task.isCancelled {
+                    let nextDomain = domains[nextIndex]
+                    nextIndex += 1
+                    enqueueBatchLookup(domain: nextDomain, source: source, group: &group)
+                }
             }
+        }
+
+        finishBatchLookup(source: source)
+    }
 
-            batchCompletedCount = index + 1
+    private func enqueueBatchLookup(
+        domain: String,
+        source: BatchLookupSource,
+        group: inout TaskGroup<(String, BatchLookupPayload?)>
+    ) {
+        activeBatchDomains.append(domain)
+        batchCurrentDomain = activeBatchDomains.first
+        if source == .watchlistRefresh {
+            refreshingTrackedDomainID = trackedDomain(for: domain)?.id
+        }
+        updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil)
+
+        group.addTask { [domain] in
+            let payload = await Self.performBatchLookup(domain: domain)
+            return (domain, payload)
         }
+    }
+
+    private func completeBatchLookup(domain: String, payload: BatchLookupPayload?) {
+        activeBatchDomains.removeAll { $0.caseInsensitiveCompare(domain) == .orderedSame }
+        batchCurrentDomain = activeBatchDomains.first
 
+        guard let payload else {
+            updateBatchResult(
+                domain: domain,
+                status: .failed,
+                quickStatus: "Failed",
+                entry: nil,
+                errorMessage: "Lookup cancelled"
+            )
+            batchCompletedCount += 1
+            return
+        }
+
+        let entry = saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false)
+        let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot)
+        let quickStatus: String
+        if entry?.changeSummary?.hasChanges == true {
+            quickStatus = entry?.changeSummary?.severity == .high ? "High" : "Changed"
+        } else if certificateWarningLevel != .none {
+            quickStatus = certificateWarningLevel == .critical ? "Critical" : "Warning"
+        } else {
+            quickStatus = "Unchanged"
+        }
+
+        updateBatchResult(
+            domain: domain,
+            status: .completed,
+            quickStatus: quickStatus,
+            entry: entry,
+            errorMessage: nil
+        )
+        batchCompletedCount += 1
+    }
+
+    private func finishBatchLookup(source: BatchLookupSource) {
         batchLookupRunning = false
         batchCurrentDomain = nil
+        activeBatchDomains = []
         refreshingTrackedDomainID = nil
+        batchTask = nil
+
+        let summary = BatchSweepSummary(
+            source: source,
+            totalDomains: batchResults.count,
+            changedDomains: batchResults.filter { ($0.changeSeverity ?? .low) >= .medium }.count,
+            unchangedDomains: batchResults.filter { ($0.changeSeverity ?? .low) < .medium && $0.certificateWarningLevel == .none && $0.status == .completed }.count,
+            warningDomains: batchResults.filter { $0.certificateWarningLevel != .none }.count,
+            results: batchResults.sorted { lhs, rhs in
+                if lhs.status != rhs.status {
+                    return lhs.status.rawValue < rhs.status.rawValue
+                }
+                return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
+            },
+            generatedAt: Date()
+        )
+        latestBatchSweepSummary = summary
+
+        if notificationsAuthorized {
+            Task {
+                await LocalNotificationService.shared.notifySweepComplete(summary: summary)
+            }
+        }
     }
 
     private func updateBatchResult(
@@ -1348,6 +1715,9 @@ final class DomainViewModel {
             availability: entry?.availabilityResult?.status,
             primaryIP: entry?.primaryIP,
             quickStatus: quickStatus,
+            summaryMessage: entry?.changeSummary?.message,
+            changeSeverity: entry?.changeSummary?.severity,
+            certificateWarningLevel: entry.map { DomainDiffService.certificateWarningLevel(for: $0.snapshot) } ?? batchResults[index].certificateWarningLevel,
             timestamp: entry?.timestamp ?? Date(),
             status: status,
             errorMessage: errorMessage
@@ -1594,8 +1964,8 @@ final class DomainViewModel {
             SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary),
             SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary),
             SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)),
-            SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary),
-            SummaryFieldViewData(label: "Email", value: emailSummary(from: snapshot), tone: .secondary)
+            SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)),
+            SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary)
         ]
     }
 
@@ -1614,6 +1984,16 @@ final class DomainViewModel {
             ),
             at: 1
         )
+        if let certificateStatus = certificateBadgeLabel(from: snapshot) {
+            rows.insert(
+                InfoRowViewData(
+                    label: "Certificate",
+                    value: certificateStatus,
+                    tone: certificateStatusTone(from: snapshot)
+                ),
+                at: 2
+            )
+        }
         return rows
     }
 
@@ -1663,7 +2043,7 @@ final class DomainViewModel {
             InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary),
             InfoRowViewData(label: "Valid From", value: certificateDateFormatter.string(from: sslInfo.validFrom), tone: .secondary),
             InfoRowViewData(label: "Valid Until", value: certificateDateFormatter.string(from: sslInfo.validUntil), tone: .secondary),
-            InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: sslInfo.daysUntilExpiry < 30 ? .failure : (sslInfo.daysUntilExpiry < 60 ? .warning : .success)),
+            InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: certificateTone(daysRemaining: sslInfo.daysUntilExpiry)),
             InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary)
         ]
         if let tlsVersion = sslInfo.tlsVersion {
@@ -1855,7 +2235,8 @@ final class DomainViewModel {
                 lines.append("  \(item.label): \(item.value)")
             }
             if let changeSummary {
-                lines.append("  Change Summary: \(changeSummary.hasChanges ? "Changed" : "Unchanged")")
+                lines.append("  Change Summary: \(changeSummary.message)")
+                lines.append("  Severity: \(changeSummary.severity.title)")
                 lines.append("  Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))")
                 lines.append("  Compared At: \(exportDateFormatter.string(from: changeSummary.generatedAt))")
             }
@@ -2080,6 +2461,43 @@ final class DomainViewModel {
         return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")"
     }
 
+    private static func certificateStatusLabel(from snapshot: LookupSnapshot) -> String {
+        guard let sslInfo = snapshot.sslInfo else {
+            return snapshot.sslError ?? "Unavailable"
+        }
+
+        switch DomainDiffService.certificateWarningLevel(for: snapshot) {
+        case .critical:
+            return "Critical (\(sslInfo.daysUntilExpiry)d)"
+        case .warning:
+            return "Warning (\(sslInfo.daysUntilExpiry)d)"
+        case .none:
+            return "Healthy (\(sslInfo.daysUntilExpiry)d)"
+        }
+    }
+
+    private static func certificateBadgeLabel(from snapshot: LookupSnapshot) -> String? {
+        guard snapshot.sslInfo != nil else { return nil }
+        return certificateStatusLabel(from: snapshot)
+    }
+
+    private static func certificateStatusTone(from snapshot: LookupSnapshot) -> ResultTone {
+        guard let daysRemaining = snapshot.sslInfo?.daysUntilExpiry else {
+            return snapshot.sslError == nil ? .secondary : .failure
+        }
+        return certificateTone(daysRemaining: daysRemaining)
+    }
+
+    private static func certificateTone(daysRemaining: Int) -> ResultTone {
+        if daysRemaining < 14 {
+            return .failure
+        }
+        if daysRemaining < 30 {
+            return .warning
+        }
+        return .success
+    }
+
     private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
         switch status {
         case .available:
diff --git a/DomainDig/LocalNotificationService.swift b/DomainDig/LocalNotificationService.swift
new file mode 100644
index 0000000..450d844
--- /dev/null
+++ b/DomainDig/LocalNotificationService.swift
@@ -0,0 +1,89 @@
+import Foundation
+import UserNotifications
+
+@MainActor
+final class LocalNotificationService {
+    static let shared = LocalNotificationService()
+
+    private init() {}
+
+    func configureForegroundPresentation() {
+        UNUserNotificationCenter.current().delegate = NotificationCenterDelegate.shared
+    }
+
+    func requestAuthorizationIfNeeded() async -> Bool {
+        let center = UNUserNotificationCenter.current()
+        let settings = await center.notificationSettings()
+
+        switch settings.authorizationStatus {
+        case .authorized, .provisional, .ephemeral:
+            return true
+        case .notDetermined:
+            return (try? await center.requestAuthorization(options: [.alert, .badge, .sound])) ?? false
+        case .denied:
+            return false
+        @unknown default:
+            return false
+        }
+    }
+
+    func notifyDomainEvent(domain: String, message: String, severity: ChangeSeverity) async {
+        await schedule(
+            identifier: "domain-change-\(domain)",
+            title: domain,
+            body: message,
+            interruptionLevel: severity == .high ? .timeSensitive : .active
+        )
+    }
+
+    func notifyCertificateWarning(domain: String, daysRemaining: Int) async {
+        await schedule(
+            identifier: "cert-warning-\(domain)",
+            title: domain,
+            body: "Certificate expires in \(daysRemaining) days",
+            interruptionLevel: .timeSensitive
+        )
+    }
+
+    func notifySweepComplete(summary: BatchSweepSummary) async {
+        let body = "\(summary.changedDomains) changed, \(summary.warningDomains) warnings, \(summary.unchangedDomains) unchanged"
+        await schedule(
+            identifier: "sweep-complete",
+            title: summary.source == .watchlistRefresh ? "Check All Complete" : "Batch Complete",
+            body: body,
+            interruptionLevel: .active
+        )
+    }
+
+    private func schedule(
+        identifier: String,
+        title: String,
+        body: String,
+        interruptionLevel: UNNotificationInterruptionLevel
+    ) async {
+        let content = UNMutableNotificationContent()
+        content.title = title
+        content.body = body
+        content.sound = .default
+        content.interruptionLevel = interruptionLevel
+
+        let request = UNNotificationRequest(
+            identifier: identifier,
+            content: content,
+            trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
+        )
+
+        try? await UNUserNotificationCenter.current().add(request)
+    }
+}
+
+private final class NotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate {
+    static let shared = NotificationCenterDelegate()
+
+    func userNotificationCenter(
+        _ center: UNUserNotificationCenter,
+        willPresent notification: UNNotification
+    ) async -> UNNotificationPresentationOptions {
+        [.banner, .list, .sound]
+    }
+}
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index d11e879..2e0a5c2 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -48,10 +48,74 @@ struct WatchedDomain: Codable, Identifiable {
     }
 }
 
+enum ChangeSeverity: Int, Codable, CaseIterable, Comparable {
+    case low
+    case medium
+    case high
+
+    static func < (lhs: ChangeSeverity, rhs: ChangeSeverity) -> Bool {
+        lhs.rawValue < rhs.rawValue
+    }
+
+    var title: String {
+        switch self {
+        case .low:
+            return "Low"
+        case .medium:
+            return "Medium"
+        case .high:
+            return "High"
+        }
+    }
+}
+
+enum CertificateWarningLevel: String, Codable {
+    case none
+    case warning
+    case critical
+
+    var title: String {
+        switch self {
+        case .none:
+            return "Healthy"
+        case .warning:
+            return "Warning"
+        case .critical:
+            return "Critical"
+        }
+    }
+}
+
 struct DomainChangeSummary: Codable, Equatable {
     let hasChanges: Bool
     let changedSections: [String]
+    let message: String
+    let severity: ChangeSeverity
     let generatedAt: Date
+
+    init(
+        hasChanges: Bool,
+        changedSections: [String],
+        message: String,
+        severity: ChangeSeverity,
+        generatedAt: Date
+    ) {
+        self.hasChanges = hasChanges
+        self.changedSections = changedSections
+        self.message = message
+        self.severity = severity
+        self.generatedAt = generatedAt
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        hasChanges = try container.decodeIfPresent(Bool.self, forKey: .hasChanges) ?? false
+        changedSections = try container.decodeIfPresent([String].self, forKey: .changedSections) ?? []
+        generatedAt = try container.decode(Date.self, forKey: .generatedAt)
+        severity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severity) ?? (hasChanges ? .medium : .low)
+        message = try container.decodeIfPresent(String.self, forKey: .message)
+            ?? (changedSections.isEmpty ? "No meaningful changes" : changedSections.joined(separator: " • "))
+    }
 }
 
 enum BatchLookupSource: String, Codable {
@@ -73,6 +137,9 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
     let availability: DomainAvailabilityStatus?
     let primaryIP: String?
     let quickStatus: String
+    let summaryMessage: String?
+    let changeSeverity: ChangeSeverity?
+    let certificateWarningLevel: CertificateWarningLevel
     let timestamp: Date
     let status: BatchLookupStatus
     let errorMessage: String?
@@ -84,6 +151,9 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
         availability: DomainAvailabilityStatus?,
         primaryIP: String?,
         quickStatus: String,
+        summaryMessage: String? = nil,
+        changeSeverity: ChangeSeverity? = nil,
+        certificateWarningLevel: CertificateWarningLevel = .none,
         timestamp: Date,
         status: BatchLookupStatus,
         errorMessage: String? = nil
@@ -94,12 +164,26 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
         self.availability = availability
         self.primaryIP = primaryIP
         self.quickStatus = quickStatus
+        self.summaryMessage = summaryMessage
+        self.changeSeverity = changeSeverity
+        self.certificateWarningLevel = certificateWarningLevel
         self.timestamp = timestamp
         self.status = status
         self.errorMessage = errorMessage
     }
 }
 
+struct BatchSweepSummary: Identifiable, Equatable {
+    let id = UUID()
+    let source: BatchLookupSource
+    let totalDomains: Int
+    let changedDomains: Int
+    let unchangedDomains: Int
+    let warningDomains: Int
+    let results: [BatchLookupResult]
+    let generatedAt: Date
+}
+
 enum HistoryDateFilter: String, CaseIterable, Identifiable {
     case today
     case last7Days
@@ -213,6 +297,9 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
     var lastKnownAvailability: DomainAvailabilityStatus?
     var lastSnapshotID: UUID?
     var lastChangeSummary: DomainChangeSummary?
+    var lastChangeSeverity: ChangeSeverity?
+    var certificateWarningLevel: CertificateWarningLevel
+    var certificateDaysRemaining: Int?
 
     init(
         id: UUID = UUID(),
@@ -223,7 +310,10 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
         isPinned: Bool = false,
         lastKnownAvailability: DomainAvailabilityStatus? = nil,
         lastSnapshotID: UUID? = nil,
-        lastChangeSummary: DomainChangeSummary? = nil
+        lastChangeSummary: DomainChangeSummary? = nil,
+        lastChangeSeverity: ChangeSeverity? = nil,
+        certificateWarningLevel: CertificateWarningLevel = .none,
+        certificateDaysRemaining: Int? = nil
     ) {
         self.id = id
         self.domain = domain
@@ -234,6 +324,25 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
         self.lastKnownAvailability = lastKnownAvailability
         self.lastSnapshotID = lastSnapshotID
         self.lastChangeSummary = lastChangeSummary
+        self.lastChangeSeverity = lastChangeSeverity
+        self.certificateWarningLevel = certificateWarningLevel
+        self.certificateDaysRemaining = certificateDaysRemaining
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
+        domain = try container.decode(String.self, forKey: .domain)
+        createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
+        updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
+        note = try container.decodeIfPresent(String.self, forKey: .note)
+        isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false
+        lastKnownAvailability = try container.decodeIfPresent(DomainAvailabilityStatus.self, forKey: .lastKnownAvailability)
+        lastSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .lastSnapshotID)
+        lastChangeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .lastChangeSummary)
+        lastChangeSeverity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .lastChangeSeverity) ?? lastChangeSummary?.severity
+        certificateWarningLevel = try container.decodeIfPresent(CertificateWarningLevel.self, forKey: .certificateWarningLevel) ?? .none
+        certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining)
     }
 }
 
diff --git a/DomainDig/SSLCheckService.swift b/DomainDig/SSLCheckService.swift
index e180d4e..73c00bd 100644
--- a/DomainDig/SSLCheckService.swift
+++ b/DomainDig/SSLCheckService.swift
@@ -65,24 +65,28 @@ struct SSLCheckService {
         let validFrom: Date
         let validUntil: Date
 
-        if let notBefore = SecCertificateCopyNotValidBeforeDate(leaf) as Date? {
-            validFrom = notBefore
-        } else {
-            validFrom = Date.distantPast
-        }
+        let derData = SecCertificateCopyData(leaf) as Data
+        let parsed = DERCertificateParser.parse(derData)
+
+        if #available(iOS 18.0, *) {
+            if let notBefore = SecCertificateCopyNotValidBeforeDate(leaf) as Date? {
+                validFrom = notBefore
+            } else {
+                validFrom = parsed.notBefore ?? Date.distantPast
+            }
 
-        if let notAfter = SecCertificateCopyNotValidAfterDate(leaf) as Date? {
-            validUntil = notAfter
+            if let notAfter = SecCertificateCopyNotValidAfterDate(leaf) as Date? {
+                validUntil = notAfter
+            } else {
+                validUntil = parsed.notAfter ?? Date.distantFuture
+            }
         } else {
-            validUntil = Date.distantFuture
+            validFrom = parsed.notBefore ?? Date.distantPast
+            validUntil = parsed.notAfter ?? Date.distantFuture
         }
 
         let daysUntilExpiry = Calendar.current.dateComponents([.day], from: Date(), to: validUntil).day ?? 0
 
-        // Parse the DER-encoded certificate to extract SANs and Issuer
-        let derData = SecCertificateCopyData(leaf) as Data
-        let parsed = DERCertificateParser.parse(derData)
-
         let sans = parsed.subjectAltNames.isEmpty ? [commonName] : parsed.subjectAltNames
 
         // Issuer: prefer parsed issuer, fall back to chain's next cert summary
@@ -116,6 +120,7 @@ struct SSLCheckService {
             chain: chain
         )
     }
+
 }
 
 fileprivate struct TLSMetadata {
@@ -133,6 +138,8 @@ private enum DERCertificateParser {
     struct Result {
         var issuerCommonName: String?
         var subjectAltNames: [String] = []
+        var notBefore: Date?
+        var notAfter: Date?
     }
 
     static func parse(_ data: Data) -> Result {
@@ -171,8 +178,11 @@ private enum DERCertificateParser {
             offset = issuerSeq.contentStart + issuerSeq.length
         }
 
-        // Skip validity
+        // Validity
         if let validity = readTagAndLength(bytes, offset: offset) {
+            let (notBefore, notAfter) = extractValidity(bytes, sequenceStart: validity.contentStart, length: validity.length)
+            result.notBefore = notBefore
+            result.notAfter = notAfter
             offset = validity.contentStart + validity.length
         }
 
@@ -287,6 +297,43 @@ private enum DERCertificateParser {
         return sans
     }
 
+    private static func extractValidity(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> (Date?, Date?) {
+        let end = sequenceStart + length
+        var position = sequenceStart
+        var dates: [Date] = []
+
+        while position < end, dates.count < 2 {
+            guard let timeTL = readTagAndLength(bytes, offset: position) else { break }
+            let raw = String(bytes: bytes[timeTL.contentStart..<timeTL.contentStart + timeTL.length], encoding: .ascii)
+            if let raw {
+                dates.append(parseASN1Time(raw))
+            }
+            position = timeTL.contentStart + timeTL.length
+        }
+
+        let notBefore = dates.indices.contains(0) ? dates[0] : nil
+        let notAfter = dates.indices.contains(1) ? dates[1] : nil
+        return (notBefore, notAfter)
+    }
+
+    private static func parseASN1Time(_ string: String) -> Date {
+        let utcFormatter = DateFormatter()
+        utcFormatter.locale = Locale(identifier: "en_US_POSIX")
+        utcFormatter.timeZone = TimeZone(secondsFromGMT: 0)
+        utcFormatter.dateFormat = "yyMMddHHmmss'Z'"
+
+        if let date = utcFormatter.date(from: string) {
+            return date
+        }
+
+        let generalizedFormatter = DateFormatter()
+        generalizedFormatter.locale = Locale(identifier: "en_US_POSIX")
+        generalizedFormatter.timeZone = TimeZone(secondsFromGMT: 0)
+        generalizedFormatter.dateFormat = "yyyyMMddHHmmss'Z'"
+
+        return generalizedFormatter.date(from: string) ?? Date.distantFuture
+    }
+
     private struct TLV {
         let contentStart: Int
         let length: Int
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index b01b8f9..1f03a0d 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -9,12 +9,20 @@ struct WatchlistView: View {
             if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
                 Section("Refresh Progress") {
                     VStack(alignment: .leading, spacing: 8) {
-                        if viewModel.batchLookupRunning {
-                            ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1)))
-                                .tint(.cyan)
+                        ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1)))
+                            .tint(.cyan)
+                        HStack {
                             Text(viewModel.batchProgressLabel)
                                 .font(.system(.caption, design: .monospaced))
                                 .foregroundStyle(.secondary)
+                            Spacer()
+                            if viewModel.batchLookupRunning {
+                                Button("Cancel") {
+                                    viewModel.cancelBatchLookup()
+                                }
+                                .buttonStyle(.bordered)
+                                .font(.system(.caption2, design: .monospaced))
+                            }
                         }
 
                         ForEach(viewModel.batchResults.prefix(5)) { result in
@@ -129,9 +137,10 @@ struct WatchlistView: View {
                             }
                         }
 
-                        Button("Refresh All") {
+                        Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") {
                             viewModel.refreshAllTrackedDomains()
                         }
+                        .disabled(viewModel.batchLookupRunning)
 
                         Button("Export TXT") {
                             shareTrackedDomains(asCSV: false)
@@ -151,9 +160,19 @@ struct WatchlistView: View {
         .onChange(of: viewModel.rerunNavigationToken) { _, _ in
             dismiss()
         }
+        .sheet(item: batchSummaryBinding) { summary in
+            BatchSweepSummaryView(viewModel: viewModel, summary: summary)
+        }
         .preferredColorScheme(.dark)
     }
 
+    private var batchSummaryBinding: Binding<BatchSweepSummary?> {
+        Binding(
+            get: { viewModel.latestBatchSweepSummary },
+            set: { viewModel.latestBatchSweepSummary = $0 }
+        )
+    }
+
     private func deleteFilteredTrackedDomains(at offsets: IndexSet) {
         let domains = offsets.map { viewModel.filteredTrackedDomains[$0] }
         domains.forEach(viewModel.deleteTrackedDomain)
@@ -197,13 +216,15 @@ struct WatchlistRowView: View {
                 .font(.system(.caption2, design: .monospaced))
                 .foregroundStyle(.secondary)
 
+            indicatorRow
+
             if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
                 Text(note)
                     .font(.system(.caption, design: .monospaced))
                     .foregroundStyle(.secondary)
                     .lineLimit(2)
             } else if let summary = trackedDomain.lastChangeSummary {
-                Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • "))
+                Text(summary.message)
                     .font(.system(.caption, design: .monospaced))
                     .foregroundStyle(.secondary)
                     .lineLimit(2)
@@ -270,6 +291,43 @@ struct WatchlistRowView: View {
             return Color(.systemGray5).opacity(0.6)
         }
     }
+
+    @ViewBuilder
+    private var indicatorRow: some View {
+        HStack(spacing: 8) {
+            if let severity = trackedDomain.lastChangeSeverity, severity >= .medium {
+                Label(severity.title, systemImage: severity == .high ? "exclamationmark.circle.fill" : "circle.fill")
+                    .font(.system(.caption2, design: .monospaced))
+                    .foregroundStyle(severity == .high ? .red : .yellow)
+            } else {
+                Label("Stable", systemImage: "circle.fill")
+                    .font(.system(.caption2, design: .monospaced))
+                    .foregroundStyle(.secondary)
+            }
+
+            if trackedDomain.certificateWarningLevel != .none {
+                Text(certificateLabel)
+                    .font(.system(.caption2, design: .monospaced))
+                    .foregroundStyle(trackedDomain.certificateWarningLevel == .critical ? .red : .yellow)
+                    .padding(.horizontal, 8)
+                    .padding(.vertical, 4)
+                    .background((trackedDomain.certificateWarningLevel == .critical ? Color.red : Color.yellow).opacity(0.16))
+                    .clipShape(Capsule())
+            }
+        }
+    }
+
+    private var certificateLabel: String {
+        let days = trackedDomain.certificateDaysRemaining.map { "\($0)d" } ?? "Soon"
+        switch trackedDomain.certificateWarningLevel {
+        case .critical:
+            return "Cert \(days)"
+        case .warning:
+            return "Warn \(days)"
+        case .none:
+            return ""
+        }
+    }
 }
 
 struct TrackedDomainDetailView: View {