krz/domain-dig

an ios app for DNS & SSL analysis

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

fd34553ab0e63b4d3bc22532b61f4fef32846f34

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-25T05:03:59Z

DomainDig v3.9.0 — Portfolio Dashboard

* Portfolio summary cards
* Recent activity feed across tracked domains
* Attention-required queue prioritized by severity
* Certificate expiry visibility and expiring-soon section
* Quick portfolio filters for triage
* Deterministic per-domain health scoring
* Local portfolio search
* Apex-domain grouping for tracked assets
 DomainDig.xcodeproj/project.pbxproj                |   8 +-
 .../xcschemes/xcschememanagement.plist             |   4 +-
 DomainDig/ContentView.swift                        |  10 +-
 DomainDig/DashboardView.swift                      | 383 +++++++++++++++++
 DomainDig/DomainViewModel.swift                    | 474 +++++++++++++++++++++
 DomainDig/Models.swift                             |  94 ++++
 DomainDig/RootTabView.swift                        |  42 +-
 DomainReportBuilder.swift                          |  34 +-
 8 files changed, 1025 insertions(+), 24 deletions(-)

diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index d015b63..a6799c2 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -366,7 +366,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 30;
+				CURRENT_PROJECT_VERSION = 31;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -383,7 +383,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.8.0;
+				MARKETING_VERSION = 3.9.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -403,7 +403,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 30;
+				CURRENT_PROJECT_VERSION = 31;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -420,7 +420,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.8.0;
+				MARKETING_VERSION = 3.9.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
index b7ae4b0..fbb9f70 100644
--- a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -7,12 +7,12 @@
 		<key>DomainDig.xcscheme_^#shared#^_</key>
 		<dict>
 			<key>orderHint</key>
-			<integer>1</integer>
+			<integer>0</integer>
 		</dict>
 		<key>DomainDigCLI.xcscheme_^#shared#^_</key>
 		<dict>
 			<key>orderHint</key>
-			<integer>0</integer>
+			<integer>1</integer>
 		</dict>
 	</dict>
 	<key>SuppressBuildableAutocreation</key>
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index 5f119ca..754909c 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -2518,6 +2518,10 @@ struct SettingsView: View {
             }
 
             Section("Preferences") {
+                NavigationLink("Tracked Domains") {
+                    WatchlistView(viewModel: viewModel)
+                }
+
                 NavigationLink("Workflows") {
                     WorkflowsView(viewModel: viewModel)
                 }
@@ -2532,6 +2536,10 @@ struct SettingsView: View {
             }
 
             Section("Services") {
+                NavigationLink("Monitoring Activity") {
+                    MonitoringView(viewModel: viewModel)
+                }
+
                 NavigationLink("iCloud Sync") {
                     CloudSyncSettingsView()
                 }
@@ -2557,7 +2565,7 @@ struct SettingsView: View {
                 }
             }
         }
-        .navigationTitle("More")
+        .navigationTitle("Settings")
     }
 }
 
diff --git a/DomainDig/DashboardView.swift b/DomainDig/DashboardView.swift
new file mode 100644
index 0000000..087f080
--- /dev/null
+++ b/DomainDig/DashboardView.swift
@@ -0,0 +1,383 @@
+import SwiftUI
+
+struct DashboardView: View {
+    @Environment(\.appDensity) private var appDensity
+    @Bindable var viewModel: DomainViewModel
+    @State private var collapsedGroups = Set<String>()
+
+    private let summaryColumns = [
+        GridItem(.flexible(), spacing: 10),
+        GridItem(.flexible(), spacing: 10)
+    ]
+
+    var body: some View {
+        List {
+            if viewModel.trackedDomains.isEmpty {
+                Section {
+                    EmptyStateCardView(
+                        title: "No Portfolio Yet",
+                        message: "Track domains to get portfolio health, recent changes, expiry visibility, and an attention queue in one place.",
+                        suggestion: "Inspect a domain and use the Track action, or add one directly from Tracked Domains in Settings.",
+                        systemImage: "square.stack.3d.up.fill",
+                        showsCardBackground: false
+                    )
+                }
+                .listRowBackground(Color(.systemGray6).opacity(0.5))
+            } else {
+                Section {
+                    LazyVGrid(columns: summaryColumns, spacing: 10) {
+                        summaryCard(title: "Total Domains", value: viewModel.portfolioDashboardData.snapshot.totalDomains, filter: .all, tint: .cyan)
+                        summaryCard(title: "Healthy", value: viewModel.portfolioDashboardData.snapshot.healthyCount, filter: .healthy, tint: .green)
+                        summaryCard(title: "Warning", value: viewModel.portfolioDashboardData.snapshot.warningCount, filter: .warning, tint: .yellow)
+                        summaryCard(title: "Critical", value: viewModel.portfolioDashboardData.snapshot.criticalCount, filter: .critical, tint: .red)
+                        summaryCard(title: "Changes (24h)", value: viewModel.portfolioDashboardData.snapshot.changedLast24h, filter: .changed, tint: .orange)
+                        summaryCard(title: "Unreachable", value: viewModel.portfolioDashboardData.snapshot.unreachableCount, filter: .unreachable, tint: .pink)
+                    }
+                    .padding(.vertical, 4)
+                }
+                .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+                Section("Quick Filters") {
+                    ScrollView(.horizontal, showsIndicators: false) {
+                        HStack(spacing: 8) {
+                            ForEach(PortfolioFilterOption.allCases) { filter in
+                                Button {
+                                    viewModel.dashboardFilter = filter
+                                } label: {
+                                    Text(filter.title)
+                                        .font(appDensity.font(.caption, weight: .semibold))
+                                        .foregroundStyle(viewModel.dashboardFilter == filter ? Color.black : Color.white)
+                                        .padding(.horizontal, 12)
+                                        .padding(.vertical, 8)
+                                        .background(viewModel.dashboardFilter == filter ? Color.cyan : Color(.systemGray5).opacity(0.6))
+                                        .clipShape(Capsule())
+                                }
+                                .buttonStyle(.plain)
+                            }
+                        }
+                        .padding(.vertical, 4)
+                    }
+                }
+                .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+                Section("Recent Activity") {
+                    if viewModel.filteredPortfolioRecentActivity.isEmpty {
+                        dashboardEmptyRow("No recent portfolio changes")
+                    } else {
+                        ForEach(viewModel.filteredPortfolioRecentActivity.prefix(8)) { item in
+                            if let trackedDomain = viewModel.trackedDomain(withID: item.trackedDomainID) {
+                                NavigationLink {
+                                    TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
+                                } label: {
+                                    PortfolioActivityRow(item: item)
+                                }
+                                .buttonStyle(.plain)
+                            }
+                        }
+                    }
+                }
+                .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+                Section("Attention Required") {
+                    if viewModel.filteredPortfolioAttentionRequired.isEmpty {
+                        dashboardEmptyRow("Nothing urgent right now")
+                    } else {
+                        ForEach(viewModel.filteredPortfolioAttentionRequired.prefix(8)) { item in
+                            if let trackedDomain = viewModel.trackedDomain(withID: item.trackedDomainID) {
+                                NavigationLink {
+                                    TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
+                                } label: {
+                                    PortfolioAttentionRow(item: item)
+                                }
+                                .buttonStyle(.plain)
+                            }
+                        }
+                    }
+                }
+                .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+                Section("Expiring Soon") {
+                    if viewModel.filteredPortfolioExpiringSoon.isEmpty {
+                        dashboardEmptyRow("No certificates expiring within 30 days")
+                    } else {
+                        ForEach(viewModel.filteredPortfolioExpiringSoon.prefix(8)) { state in
+                            NavigationLink {
+                                TrackedDomainDetailView(viewModel: viewModel, trackedDomain: state.trackedDomain)
+                            } label: {
+                                PortfolioExpiryRow(state: state)
+                            }
+                            .buttonStyle(.plain)
+                        }
+                    }
+                }
+                .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+                Section("Portfolio List") {
+                    if viewModel.filteredPortfolioGroups.isEmpty {
+                        dashboardEmptyRow("No domains match the current filter")
+                    } else {
+                        ForEach(viewModel.filteredPortfolioGroups) { group in
+                            DisclosureGroup(
+                                isExpanded: disclosureBinding(for: group.apexDomain),
+                                content: {
+                                    ForEach(group.domains) { state in
+                                        NavigationLink {
+                                            TrackedDomainDetailView(viewModel: viewModel, trackedDomain: state.trackedDomain)
+                                        } label: {
+                                            WatchlistRowView(
+                                                trackedDomain: state.trackedDomain,
+                                                isRefreshing: viewModel.refreshingTrackedDomainID == state.trackedDomain.id
+                                            )
+                                        }
+                                        .buttonStyle(.plain)
+                                    }
+                                },
+                                label: {
+                                    HStack {
+                                        VStack(alignment: .leading, spacing: 4) {
+                                            Text(group.apexDomain)
+                                                .font(appDensity.font(.headline, design: .default, weight: .semibold))
+                                            Text("\(group.domains.count) domain\(group.domains.count == 1 ? "" : "s")")
+                                                .font(appDensity.font(.caption))
+                                                .foregroundStyle(.secondary)
+                                        }
+                                        Spacer()
+                                        groupBadge(for: group.domains)
+                                    }
+                                    .padding(.vertical, 4)
+                                }
+                            )
+                        }
+                    }
+                }
+                .listRowBackground(Color(.systemGray6).opacity(0.5))
+            }
+        }
+        .scrollContentBackground(.hidden)
+        .background(Color.black)
+        .navigationTitle("Dashboard")
+        .searchable(text: $viewModel.dashboardSearchText, prompt: "Search portfolio")
+        .toolbar {
+            if !viewModel.trackedDomains.isEmpty {
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button {
+                        AppHaptics.refresh()
+                        viewModel.refreshAllTrackedDomains()
+                    } label: {
+                        Image(systemName: "arrow.clockwise")
+                    }
+                    .disabled(viewModel.batchLookupRunning)
+                }
+            }
+        }
+        .preferredColorScheme(.dark)
+    }
+
+    private func disclosureBinding(for apexDomain: String) -> Binding<Bool> {
+        Binding(
+            get: { !collapsedGroups.contains(apexDomain) },
+            set: { isExpanded in
+                if isExpanded {
+                    collapsedGroups.remove(apexDomain)
+                } else {
+                    collapsedGroups.insert(apexDomain)
+                }
+            }
+        )
+    }
+
+    private func summaryCard(title: String, value: Int, filter: PortfolioFilterOption, tint: Color) -> some View {
+        Button {
+            viewModel.dashboardFilter = filter
+        } label: {
+            VStack(alignment: .leading, spacing: 8) {
+                Text(title)
+                    .font(appDensity.font(.caption, design: .default, weight: .semibold))
+                    .foregroundStyle(.secondary)
+                Text("\(value)")
+                    .font(.system(size: 28, weight: .bold, design: .rounded))
+                    .foregroundStyle(.white)
+                HStack {
+                    Circle()
+                        .fill(tint)
+                        .frame(width: 8, height: 8)
+                    Text(filter.title)
+                        .font(appDensity.font(.caption2, design: .default, weight: .semibold))
+                        .foregroundStyle(tint)
+                }
+            }
+            .frame(maxWidth: .infinity, alignment: .leading)
+            .padding(appDensity.metrics.cardPadding)
+            .background(cardBackground(for: filter))
+            .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
+        }
+        .buttonStyle(.plain)
+    }
+
+    private func cardBackground(for filter: PortfolioFilterOption) -> some ShapeStyle {
+        if viewModel.dashboardFilter == filter {
+            return AnyShapeStyle(
+                LinearGradient(
+                    colors: [Color.cyan.opacity(0.28), Color.cyan.opacity(0.12)],
+                    startPoint: .topLeading,
+                    endPoint: .bottomTrailing
+                )
+            )
+        }
+        return AnyShapeStyle(Color(.systemGray5).opacity(0.45))
+    }
+
+    private func dashboardEmptyRow(_ message: String) -> some View {
+        Text(message)
+            .font(appDensity.font(.caption))
+            .foregroundStyle(.secondary)
+            .padding(.vertical, 4)
+    }
+
+    private func groupBadge(for states: [PortfolioDomainStatus]) -> some View {
+        let criticalCount = states.filter { $0.health == .critical }.count
+        let warningCount = states.filter { $0.health == .warning }.count
+        let title: String
+        let color: Color
+
+        if criticalCount > 0 {
+            title = "\(criticalCount) critical"
+            color = .red
+        } else if warningCount > 0 {
+            title = "\(warningCount) warning"
+            color = .yellow
+        } else {
+            title = "Healthy"
+            color = .green
+        }
+
+        return AppStatusBadgeView(
+            model: .init(
+                title: title,
+                systemImage: criticalCount > 0 ? "exclamationmark.octagon.fill" : (warningCount > 0 ? "exclamationmark.triangle.fill" : "checkmark.circle.fill"),
+                foregroundColor: color,
+                backgroundColor: color.opacity(0.16)
+            )
+        )
+    }
+}
+
+private struct PortfolioActivityRow: View {
+    @Environment(\.appDensity) private var appDensity
+    let item: PortfolioActivityItem
+
+    var body: some View {
+        HStack(alignment: .top, spacing: 10) {
+            Image(systemName: item.systemImage)
+                .font(.caption.weight(.semibold))
+                .foregroundStyle(iconColor)
+                .frame(width: 22, height: 22)
+                .background(iconColor.opacity(0.14))
+                .clipShape(RoundedRectangle(cornerRadius: 7))
+
+            VStack(alignment: .leading, spacing: 4) {
+                Text(item.message)
+                    .font(appDensity.font(.callout, design: .default))
+                    .foregroundStyle(.primary)
+                HStack(spacing: 8) {
+                    Text(item.domain)
+                    Text(relativeTimestamp(item.timestamp))
+                }
+                .font(appDensity.font(.caption2))
+                .foregroundStyle(.secondary)
+            }
+        }
+        .padding(.vertical, 4)
+    }
+
+    private var iconColor: Color {
+        switch item.health {
+        case .healthy:
+            return .cyan
+        case .warning:
+            return .yellow
+        case .critical:
+            return .red
+        }
+    }
+}
+
+private struct PortfolioAttentionRow: View {
+    @Environment(\.appDensity) private var appDensity
+    let item: PortfolioAttentionItem
+
+    var body: some View {
+        HStack(alignment: .top, spacing: 10) {
+            AppStatusBadgeView(model: badgeModel)
+
+            VStack(alignment: .leading, spacing: 4) {
+                Text(item.domain)
+                    .font(appDensity.font(.callout))
+                    .foregroundStyle(.primary)
+                Text(item.reason)
+                    .font(appDensity.font(.caption, design: .default))
+                    .foregroundStyle(.secondary)
+                Text(relativeTimestamp(item.timestamp))
+                    .font(appDensity.font(.caption2))
+                    .foregroundStyle(.secondary)
+            }
+        }
+        .padding(.vertical, 4)
+    }
+
+    private var badgeModel: AppStatusBadgeModel {
+        switch item.health {
+        case .healthy:
+            return .init(title: "Healthy", systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
+        case .warning:
+            return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
+        case .critical:
+            return .init(title: "Critical", systemImage: "exclamationmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
+        }
+    }
+}
+
+private struct PortfolioExpiryRow: View {
+    @Environment(\.appDensity) private var appDensity
+    let state: PortfolioDomainStatus
+
+    var body: some View {
+        HStack {
+            VStack(alignment: .leading, spacing: 4) {
+                Text(state.trackedDomain.domain)
+                    .font(appDensity.font(.callout))
+                    .foregroundStyle(.primary)
+                Text(expirySubtitle)
+                    .font(appDensity.font(.caption))
+                    .foregroundStyle(.secondary)
+            }
+            Spacer()
+            AppStatusBadgeView(model: badgeModel)
+        }
+        .padding(.vertical, 4)
+    }
+
+    private var expirySubtitle: String {
+        if let days = state.certificateDaysRemaining {
+            return "Expires in \(days) day\(days == 1 ? "" : "s")"
+        }
+        return "Certificate needs review"
+    }
+
+    private var badgeModel: AppStatusBadgeModel {
+        switch state.certificateExpiryState {
+        case .none:
+            return .init(title: "Healthy", systemImage: "lock.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
+        case .warning:
+            return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
+        case .critical:
+            return .init(title: "Critical", systemImage: "xmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
+        }
+    }
+}
+
+private func relativeTimestamp(_ date: Date) -> String {
+    let formatter = RelativeDateTimeFormatter()
+    formatter.unitsStyle = .short
+    return formatter.localizedString(for: date, relativeTo: Date())
+}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 03e9ad4..b65c6df 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -104,6 +104,66 @@ private struct WorkflowExportPayload: Codable {
     let reports: [DomainReport]
 }
 
+struct PortfolioDomainStatus: Identifiable {
+    let trackedDomain: TrackedDomain
+    let latestEntry: HistoryEntry?
+    let report: DomainReport
+    let apexDomain: String
+    let health: DomainHealth
+    let lastChangeDate: Date?
+    let lastMonitoringFailure: Date?
+    let instabilityScore: Int
+    let certificateExpiryState: CertificateWarningLevel
+    let certificateDaysRemaining: Int?
+    let isUnreachable: Bool
+    let recentDNSChange: Bool
+    let recentCriticalChange: Bool
+    let recentFailureCount: Int
+
+    var id: UUID { trackedDomain.id }
+}
+
+struct PortfolioActivityItem: Identifiable {
+    let id: String
+    let trackedDomainID: UUID
+    let domain: String
+    let message: String
+    let timestamp: Date
+    let health: DomainHealth
+    let systemImage: String
+}
+
+struct PortfolioAttentionItem: Identifiable {
+    let id: String
+    let trackedDomainID: UUID
+    let domain: String
+    let reason: String
+    let timestamp: Date
+    let health: DomainHealth
+}
+
+struct PortfolioGroup: Identifiable {
+    let apexDomain: String
+    let domains: [PortfolioDomainStatus]
+
+    var id: String { apexDomain }
+}
+
+struct PortfolioDashboardData {
+    let snapshot: PortfolioSnapshot
+    let domainStates: [PortfolioDomainStatus]
+    let recentActivity: [PortfolioActivityItem]
+    let attentionRequired: [PortfolioAttentionItem]
+    let expiringSoon: [PortfolioDomainStatus]
+    let groups: [PortfolioGroup]
+}
+
+private struct PortfolioDashboardStamp: Equatable {
+    let trackedDomainSignature: [String]
+    let historySignature: [String]
+    let monitoringSignature: [String]
+}
+
 @MainActor
 @Observable
 final class DomainViewModel {
@@ -214,6 +274,8 @@ final class DomainViewModel {
     private var trackedDomainsPersistenceDirty = false
     private let reportBuilder = DomainReportBuilder()
     private let inspectionService = DomainInspectionService()
+    private var cachedPortfolioDashboardData: PortfolioDashboardData?
+    private var cachedPortfolioDashboardStamp: PortfolioDashboardStamp?
     private(set) var currentResultSource: LookupResultSource = .live
     private(set) var currentCachedSections: [LookupSectionKind] = []
     private(set) var currentStatusMessage: String?
@@ -246,6 +308,8 @@ final class DomainViewModel {
     var watchlistSearchText = ""
     var watchlistFilter: WatchlistFilterOption = .all
     var watchlistSortOption: WatchlistSortOption = .pinned
+    var dashboardSearchText = ""
+    var dashboardFilter: PortfolioFilterOption = .all
     var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings()
     var monitoringLogs: [MonitoringLog] = MonitoringStorage.loadLogs()
     var monitoringRunInProgress = false
@@ -377,6 +441,73 @@ final class DomainViewModel {
         return sortedTrackedDomains(from: filtered, using: watchlistSortOption)
     }
 
+    var portfolioDashboardData: PortfolioDashboardData {
+        let stamp = portfolioDashboardStamp()
+        if let cachedPortfolioDashboardData, cachedPortfolioDashboardStamp == stamp {
+            return cachedPortfolioDashboardData
+        }
+
+        let domainStates = buildPortfolioDomainStates()
+        let recentActivity = buildPortfolioActivity(from: domainStates)
+        let attentionRequired = buildAttentionQueue(from: domainStates)
+        let expiringSoon = domainStates
+            .filter { $0.certificateExpiryState != .none }
+            .sorted {
+                ($0.certificateDaysRemaining ?? .max, $0.trackedDomain.domain)
+                    < ($1.certificateDaysRemaining ?? .max, $1.trackedDomain.domain)
+            }
+        let groups = buildPortfolioGroups(from: domainStates)
+        let snapshot = PortfolioSnapshot(
+            totalDomains: domainStates.count,
+            healthyCount: domainStates.filter { $0.health == .healthy }.count,
+            warningCount: domainStates.filter { $0.health == .warning }.count,
+            criticalCount: domainStates.filter { $0.health == .critical }.count,
+            changedLast24h: domainStates.filter {
+                guard let lastChangeDate = $0.lastChangeDate else { return false }
+                return Date().timeIntervalSince(lastChangeDate) <= 24 * 60 * 60
+            }.count,
+            expiringSoonCount: expiringSoon.count,
+            unreachableCount: domainStates.filter(\.isUnreachable).count
+        )
+        let data = PortfolioDashboardData(
+            snapshot: snapshot,
+            domainStates: domainStates,
+            recentActivity: recentActivity,
+            attentionRequired: attentionRequired,
+            expiringSoon: expiringSoon,
+            groups: groups
+        )
+        cachedPortfolioDashboardStamp = stamp
+        cachedPortfolioDashboardData = data
+        return data
+    }
+
+    var filteredPortfolioDomainStates: [PortfolioDomainStatus] {
+        let query = dashboardSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
+        return portfolioDashboardData.domainStates.filter { state in
+            matchesPortfolioFilter(state) && matchesDashboardSearch(state, query: query)
+        }
+    }
+
+    var filteredPortfolioGroups: [PortfolioGroup] {
+        buildPortfolioGroups(from: filteredPortfolioDomainStates)
+    }
+
+    var filteredPortfolioRecentActivity: [PortfolioActivityItem] {
+        let visibleDomainIDs = Set(filteredPortfolioDomainStates.map(\.trackedDomain.id))
+        return portfolioDashboardData.recentActivity.filter { visibleDomainIDs.contains($0.trackedDomainID) }
+    }
+
+    var filteredPortfolioAttentionRequired: [PortfolioAttentionItem] {
+        let visibleDomainIDs = Set(filteredPortfolioDomainStates.map(\.trackedDomain.id))
+        return portfolioDashboardData.attentionRequired.filter { visibleDomainIDs.contains($0.trackedDomainID) }
+    }
+
+    var filteredPortfolioExpiringSoon: [PortfolioDomainStatus] {
+        let visibleDomainIDs = Set(filteredPortfolioDomainStates.map(\.trackedDomain.id))
+        return portfolioDashboardData.expiringSoon.filter { visibleDomainIDs.contains($0.trackedDomain.id) }
+    }
+
     var timelineDomains: [String] {
         let query = timelineDomainFilter.trimmingCharacters(in: .whitespacesAndNewlines)
         let domains = history.map(\.domain)
@@ -2807,6 +2938,349 @@ final class DomainViewModel {
         recentSnapshots(for: trackedDomain, limit: 1).first?.snapshot
     }
 
+    func trackedDomain(withID id: UUID) -> TrackedDomain? {
+        trackedDomains.first(where: { $0.id == id })
+    }
+
+    private func portfolioDashboardStamp() -> PortfolioDashboardStamp {
+        PortfolioDashboardStamp(
+            trackedDomainSignature: trackedDomains
+                .sorted { $0.id.uuidString < $1.id.uuidString }
+                .map {
+                    [
+                        $0.id.uuidString,
+                        $0.updatedAt.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
+                        String($0.pendingMonitoringAlerts.count),
+                        $0.lastMonitoredAt?.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))) ?? "0",
+                        $0.lastAlertAt?.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))) ?? "0"
+                    ].joined(separator: "|")
+                },
+            historySignature: history
+                .prefix(250)
+                .map {
+                    [
+                        $0.id.uuidString,
+                        $0.timestamp.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
+                        String($0.changeCount),
+                        $0.severitySummary?.rawValue.description ?? "-"
+                    ].joined(separator: "|")
+                },
+            monitoringSignature: monitoringLogs
+                .prefix(100)
+                .map {
+                    [
+                        $0.id.uuidString,
+                        $0.timestamp.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
+                        String($0.alertsTriggered),
+                        String($0.changesFound)
+                    ].joined(separator: "|")
+                }
+        )
+    }
+
+    private func buildPortfolioDomainStates() -> [PortfolioDomainStatus] {
+        let now = Date()
+        return sortedTrackedDomains(from: trackedDomains, using: .pinned).map { trackedDomain in
+            let recentEntries = recentSnapshots(for: trackedDomain, limit: 8)
+            let latestEntry = recentEntries.first
+            let report = latestEntry.map { self.report(for: $0) } ?? reportBuilder.build(from: placeholderSnapshot(for: trackedDomain))
+            let recentMonitoringResults = monitoringLogs
+                .flatMap(\.checkedDomains)
+                .filter { $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }
+                .sorted { $0.checkedAt > $1.checkedAt }
+            let recentFailureResults = recentMonitoringResults.filter {
+                let isFailure = $0.errorMessage != nil || ($0.alertSeverity ?? .info) >= .warning
+                return isFailure && now.timeIntervalSince($0.checkedAt) <= 7 * 24 * 60 * 60
+            }
+            let recentChangeCount = recentEntries.filter {
+                guard let changeSummary = $0.changeSummary, changeSummary.hasChanges else { return false }
+                return now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
+            }.count
+            let recentDNSChange = recentEntries.contains {
+                guard let changeSummary = $0.changeSummary else { return false }
+                return changeSummary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") })
+                    && now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
+            }
+            let recentCriticalChange = recentEntries.contains {
+                guard let changeSummary = $0.changeSummary else { return false }
+                return changeSummary.impactClassification == .critical
+                    && now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
+            }
+            let certificateExpiryState = trackedDomain.certificateWarningLevel == .none
+                ? report.certificateExpiryState
+                : trackedDomain.certificateWarningLevel
+            let isUnreachable: Bool = {
+                if let latestEntry {
+                    if !latestEntry.reachabilityResults.isEmpty {
+                        return !latestEntry.reachabilityResults.contains(where: \.reachable)
+                    }
+                    if latestEntry.reachabilityError != nil {
+                        return true
+                    }
+                }
+                return recentFailureResults.contains { ($0.alertSeverity ?? .info) == .critical && $0.errorMessage != nil }
+            }()
+            let hasInvalidTLS = latestEntry?.sslInfo == nil && latestEntry?.sslError != nil
+            let instabilityScore = DomainHealth.instabilityScore(
+                recentChangeCount: recentChangeCount,
+                recentFailureCount: recentFailureResults.count,
+                pendingAlertCount: trackedDomain.pendingMonitoringAlerts.count,
+                hasRecentDNSChange: recentDNSChange
+            )
+            let health = DomainHealth.classify(
+                certificateExpiryState: certificateExpiryState,
+                isReachable: !isUnreachable,
+                recentMonitoringFailureCount: recentFailureResults.count,
+                hasRecentDNSChange: recentDNSChange,
+                instabilityScore: instabilityScore,
+                hasRecentCriticalChange: recentCriticalChange,
+                hasInvalidTLS: hasInvalidTLS
+            )
+
+            return PortfolioDomainStatus(
+                trackedDomain: trackedDomain,
+                latestEntry: latestEntry,
+                report: report,
+                apexDomain: apexDomain(for: trackedDomain.domain),
+                health: health,
+                lastChangeDate: latestEntry?.changeSummary?.hasChanges == true
+                    ? latestEntry?.timestamp
+                    : trackedDomain.monitoringState.lastChangeDate ?? report.lastChangeDate,
+                lastMonitoringFailure: recentFailureResults.first?.checkedAt,
+                instabilityScore: instabilityScore,
+                certificateExpiryState: certificateExpiryState,
+                certificateDaysRemaining: trackedDomain.certificateDaysRemaining ?? latestEntry?.sslInfo?.daysUntilExpiry,
+                isUnreachable: isUnreachable,
+                recentDNSChange: recentDNSChange,
+                recentCriticalChange: recentCriticalChange,
+                recentFailureCount: recentFailureResults.count
+            )
+        }
+    }
+
+    private func buildPortfolioActivity(from domainStates: [PortfolioDomainStatus]) -> [PortfolioActivityItem] {
+        var items: [PortfolioActivityItem] = []
+        let healthByDomainID = Dictionary(uniqueKeysWithValues: domainStates.map { ($0.trackedDomain.id, $0.health) })
+
+        for state in domainStates {
+            if let latestEntry = state.latestEntry,
+               let changeSummary = latestEntry.changeSummary,
+               changeSummary.hasChanges {
+                items.append(
+                    PortfolioActivityItem(
+                        id: "history|\(latestEntry.id.uuidString)",
+                        trackedDomainID: state.trackedDomain.id,
+                        domain: state.trackedDomain.domain,
+                        message: portfolioHistoryMessage(for: latestEntry),
+                        timestamp: latestEntry.timestamp,
+                        health: state.health,
+                        systemImage: portfolioHistoryIcon(for: latestEntry)
+                    )
+                )
+            }
+        }
+
+        for log in monitoringLogs.prefix(25) {
+            for result in log.checkedDomains where result.didChange || result.errorMessage != nil || result.certificateWarningLevel != .none {
+                guard let trackedDomain = trackedDomain(for: result.domain) else { continue }
+                items.append(
+                    PortfolioActivityItem(
+                        id: "monitoring|\(log.id.uuidString)|\(result.id.uuidString)",
+                        trackedDomainID: trackedDomain.id,
+                        domain: trackedDomain.domain,
+                        message: portfolioMonitoringMessage(for: result),
+                        timestamp: result.checkedAt,
+                        health: healthByDomainID[trackedDomain.id] ?? (result.alertSeverity == .critical ? .critical : .warning),
+                        systemImage: portfolioMonitoringIcon(for: result)
+                    )
+                )
+            }
+        }
+
+        var seen = Set<String>()
+        return items
+            .sorted { $0.timestamp > $1.timestamp }
+            .filter { item in
+                let key = "\(item.domain.lowercased())|\(item.message.lowercased())"
+                return seen.insert(key).inserted
+            }
+    }
+
+    private func buildAttentionQueue(from domainStates: [PortfolioDomainStatus]) -> [PortfolioAttentionItem] {
+        domainStates.compactMap { state in
+            guard state.health != .healthy else { return nil }
+            let reason: String
+            let timestamp: Date
+
+            if state.isUnreachable {
+                reason = "Endpoint is unreachable"
+                timestamp = state.lastMonitoringFailure ?? state.trackedDomain.updatedAt
+            } else if state.certificateExpiryState == .critical {
+                reason = "Certificate is expiring in under 14 days"
+                timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
+            } else if state.certificateExpiryState == .warning {
+                reason = "Certificate expires within 30 days"
+                timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
+            } else if state.recentFailureCount >= 2 || state.instabilityScore >= 70 {
+                reason = "Repeated instability detected"
+                timestamp = state.lastMonitoringFailure ?? state.trackedDomain.updatedAt
+            } else if state.recentDNSChange {
+                reason = "DNS changed recently"
+                timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
+            } else if state.recentCriticalChange {
+                reason = "Recent critical change detected"
+                timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
+            } else {
+                reason = "Needs attention"
+                timestamp = state.trackedDomain.updatedAt
+            }
+
+            return PortfolioAttentionItem(
+                id: "\(state.trackedDomain.id.uuidString)|\(reason)",
+                trackedDomainID: state.trackedDomain.id,
+                domain: state.trackedDomain.domain,
+                reason: reason,
+                timestamp: timestamp,
+                health: state.health
+            )
+        }
+        .sorted { lhs, rhs in
+            if lhs.health != rhs.health {
+                return healthRank(lhs.health) > healthRank(rhs.health)
+            }
+            return lhs.timestamp > rhs.timestamp
+        }
+    }
+
+    private func buildPortfolioGroups(from domainStates: [PortfolioDomainStatus]) -> [PortfolioGroup] {
+        Dictionary(grouping: domainStates, by: \.apexDomain)
+            .map { apexDomain, domains in
+                PortfolioGroup(
+                    apexDomain: apexDomain,
+                    domains: domains.sorted { lhs, rhs in
+                        if lhs.health != rhs.health {
+                            return healthRank(lhs.health) > healthRank(rhs.health)
+                        }
+                        return lhs.trackedDomain.domain.localizedCaseInsensitiveCompare(rhs.trackedDomain.domain) == .orderedAscending
+                    }
+                )
+            }
+            .sorted { lhs, rhs in
+                if let lhsMostSevere = lhs.domains.map(\.health).map(healthRank).max(),
+                   let rhsMostSevere = rhs.domains.map(\.health).map(healthRank).max(),
+                   lhsMostSevere != rhsMostSevere {
+                    return lhsMostSevere > rhsMostSevere
+                }
+                return lhs.apexDomain.localizedCaseInsensitiveCompare(rhs.apexDomain) == .orderedAscending
+            }
+    }
+
+    private func matchesPortfolioFilter(_ state: PortfolioDomainStatus) -> Bool {
+        switch dashboardFilter {
+        case .all:
+            return true
+        case .healthy:
+            return state.health == .healthy
+        case .warning:
+            return state.health == .warning
+        case .critical:
+            return state.health == .critical
+        case .changed:
+            guard let lastChangeDate = state.lastChangeDate else { return false }
+            return Date().timeIntervalSince(lastChangeDate) <= 24 * 60 * 60
+        case .expiring:
+            return state.certificateExpiryState != .none
+        case .unreachable:
+            return state.isUnreachable
+        }
+    }
+
+    private func matchesDashboardSearch(_ state: PortfolioDomainStatus, query: String) -> Bool {
+        guard !query.isEmpty else { return true }
+        let normalizedQuery = query.lowercased()
+        return state.trackedDomain.domain.lowercased().contains(normalizedQuery)
+            || state.apexDomain.lowercased().contains(normalizedQuery)
+    }
+
+    private func portfolioHistoryMessage(for entry: HistoryEntry) -> String {
+        guard let summary = entry.changeSummary else {
+            return "Configuration changed for \(entry.domain)"
+        }
+        if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) {
+            return "DNS changed for \(entry.domain)"
+        }
+        if summary.changedSections.contains(where: {
+            $0.localizedCaseInsensitiveContains("certificate")
+                || $0.localizedCaseInsensitiveContains("tls")
+        }) {
+            return "Certificate updated for \(entry.domain)"
+        }
+        if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("redirect") }) {
+            return "Redirect chain changed for \(entry.domain)"
+        }
+        return summary.message
+    }
+
+    private func portfolioHistoryIcon(for entry: HistoryEntry) -> String {
+        guard let summary = entry.changeSummary else { return "clock.arrow.trianglehead.counterclockwise.rotate.90" }
+        if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) {
+            return "point.3.connected.trianglepath.dotted"
+        }
+        if summary.changedSections.contains(where: {
+            $0.localizedCaseInsensitiveContains("certificate")
+                || $0.localizedCaseInsensitiveContains("tls")
+        }) {
+            return "lock.rotation"
+        }
+        if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("redirect") }) {
+            return "arrow.triangle.branch"
+        }
+        return "clock.arrow.trianglehead.counterclockwise.rotate.90"
+    }
+
+    private func portfolioMonitoringMessage(for result: MonitoringDomainResult) -> String {
+        if result.errorMessage != nil {
+            return "Monitoring failed for \(result.domain)"
+        }
+        if result.certificateWarningLevel != .none {
+            return "Certificate needs attention for \(result.domain)"
+        }
+        if result.didChange {
+            return result.summaryMessage.isEmpty ? "Monitoring detected a change for \(result.domain)" : result.summaryMessage
+        }
+        return "Monitoring updated \(result.domain)"
+    }
+
+    private func portfolioMonitoringIcon(for result: MonitoringDomainResult) -> String {
+        if result.errorMessage != nil {
+            return "xmark.octagon.fill"
+        }
+        if result.certificateWarningLevel != .none {
+            return "exclamationmark.triangle.fill"
+        }
+        return "waveform.path.ecg"
+    }
+
+    private func apexDomain(for domain: String) -> String {
+        let parts = domain
+            .lowercased()
+            .split(separator: ".")
+            .map(String.init)
+        guard parts.count > 2 else { return domain.lowercased() }
+        return parts.suffix(2).joined(separator: ".")
+    }
+
+    private func healthRank(_ health: DomainHealth) -> Int {
+        switch health {
+        case .healthy:
+            return 0
+        case .warning:
+            return 1
+        case .critical:
+            return 2
+        }
+    }
+
     func resolverMismatchNote(for entry: HistoryEntry) -> String? {
         guard entry.resolverURLString != resolverURLString else { return nil }
         return "Current resolver differs from this snapshot. Re-running may produce different evidence."
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index f872aab..54bea10 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -176,6 +176,26 @@ enum CertificateWarningLevel: String, Codable {
     }
 }
 
+enum DomainHealth: String, Codable, Sendable, CaseIterable {
+    case healthy
+    case warning
+    case critical
+
+    var title: String {
+        rawValue.capitalized
+    }
+}
+
+struct PortfolioSnapshot: Codable, Sendable {
+    var totalDomains: Int
+    var healthyCount: Int
+    var warningCount: Int
+    var criticalCount: Int
+    var changedLast24h: Int
+    var expiringSoonCount: Int
+    var unreachableCount: Int
+}
+
 struct DomainChangeSummary: Codable, Equatable {
     let hasChanges: Bool
     let changedSections: [String]
@@ -656,6 +676,80 @@ enum WatchlistSortOption: String, CaseIterable, Identifiable {
     }
 }
 
+enum PortfolioFilterOption: String, CaseIterable, Identifiable {
+    case all
+    case healthy
+    case warning
+    case critical
+    case changed
+    case expiring
+    case unreachable
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .all:
+            return "All"
+        case .healthy:
+            return "Healthy"
+        case .warning:
+            return "Warning"
+        case .critical:
+            return "Critical"
+        case .changed:
+            return "Changed"
+        case .expiring:
+            return "Expiring"
+        case .unreachable:
+            return "Unreachable"
+        }
+    }
+}
+
+extension DomainHealth {
+    static func classify(
+        certificateExpiryState: CertificateWarningLevel,
+        isReachable: Bool,
+        recentMonitoringFailureCount: Int,
+        hasRecentDNSChange: Bool,
+        instabilityScore: Int,
+        hasRecentCriticalChange: Bool,
+        hasInvalidTLS: Bool
+    ) -> DomainHealth {
+        if !isReachable
+            || hasInvalidTLS
+            || certificateExpiryState == .critical
+            || recentMonitoringFailureCount >= 2
+            || instabilityScore >= 70
+            || hasRecentCriticalChange {
+            return .critical
+        }
+
+        if certificateExpiryState == .warning
+            || recentMonitoringFailureCount == 1
+            || hasRecentDNSChange
+            || instabilityScore >= 35 {
+            return .warning
+        }
+
+        return .healthy
+    }
+
+    static func instabilityScore(
+        recentChangeCount: Int,
+        recentFailureCount: Int,
+        pendingAlertCount: Int,
+        hasRecentDNSChange: Bool
+    ) -> Int {
+        let rawScore = (recentChangeCount * 12)
+            + (recentFailureCount * 24)
+            + (pendingAlertCount * 8)
+            + (hasRecentDNSChange ? 14 : 0)
+        return min(rawScore, 100)
+    }
+}
+
 enum PremiumCapability: String, Codable {
     case unlimitedTrackedDomains
     case automatedMonitoring
diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift
index 06a9352..cba1062 100644
--- a/DomainDig/RootTabView.swift
+++ b/DomainDig/RootTabView.swift
@@ -1,31 +1,28 @@
 import SwiftUI
 
+private enum RootTab: Hashable {
+    case dashboard
+    case history
+    case inspect
+    case settings
+}
+
 struct RootTabView: View {
     @Bindable var viewModel: DomainViewModel
     @State private var purchaseService = PurchaseService.shared
+    @State private var selectedTab: RootTab = FeatureAccessService.currentTier == .free ? .inspect : .dashboard
 
     var body: some View {
         let _ = purchaseService.currentTier
 
-        TabView {
-            ContentView(viewModel: viewModel)
-                .tabItem {
-                    Label("Inspect", systemImage: "magnifyingglass")
-                }
-
+        TabView(selection: $selectedTab) {
             NavigationStack {
-                WatchlistView(viewModel: viewModel)
+                DashboardView(viewModel: viewModel)
             }
             .tabItem {
-                Label("Watchlist", systemImage: "eye")
-            }
-
-            NavigationStack {
-                MonitoringView(viewModel: viewModel)
-            }
-            .tabItem {
-                Label("Monitoring", systemImage: "waveform.path.ecg")
+                Label("Dashboard", systemImage: "square.grid.2x2")
             }
+            .tag(RootTab.dashboard)
 
             NavigationStack {
                 HistoryView(viewModel: viewModel)
@@ -33,13 +30,21 @@ struct RootTabView: View {
             .tabItem {
                 Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
             }
+            .tag(RootTab.history)
+
+            ContentView(viewModel: viewModel)
+                .tabItem {
+                    Label("Inspect", systemImage: "magnifyingglass")
+                }
+                .tag(RootTab.inspect)
 
             NavigationStack {
                 SettingsView(viewModel: viewModel)
             }
             .tabItem {
-                Label("More", systemImage: "ellipsis.circle")
+                Label("Settings", systemImage: "gearshape")
             }
+            .tag(RootTab.settings)
         }
         .sheet(isPresented: Binding(
             get: { viewModel.isPaywallPresented },
@@ -63,5 +68,10 @@ struct RootTabView: View {
                 }
             )
         }
+        .onChange(of: purchaseService.currentTier) { _, newValue in
+            if newValue != .free, selectedTab == .inspect, viewModel.trackedDomains.isEmpty == false {
+                selectedTab = .dashboard
+            }
+        }
     }
 }
diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift
index e48b673..afed9db 100644
--- a/DomainReportBuilder.swift
+++ b/DomainReportBuilder.swift
@@ -34,6 +34,11 @@ struct DomainReport: Codable {
     let riskAssessment: DomainRiskAssessment
     let insights: [String]
     let changeSummary: DomainChangeSummary?
+    let lastChangeDate: Date?
+    let health: DomainHealth
+    let lastMonitoringFailure: Date?
+    let instabilityScore: Int
+    let certificateExpiryState: CertificateWarningLevel
     let workflowContext: DomainWorkflowContext?
     let metadata: DomainReportMetadata
 }
@@ -223,7 +228,29 @@ struct DomainReportBuilder {
         primaryIP: String?,
         changeSummary: DomainChangeSummary?
     ) -> DomainReport {
-        DomainReport(
+        let certificateExpiryState = DomainDiffService.certificateWarningLevel(for: snapshot)
+        let recentChangeCount = changeSummary?.hasChanges == true ? 1 : 0
+        let instabilityScore = DomainHealth.instabilityScore(
+            recentChangeCount: recentChangeCount,
+            recentFailureCount: 0,
+            pendingAlertCount: 0,
+            hasRecentDNSChange: changeSummary?.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) == true
+        )
+        let hasInvalidTLS = snapshot.sslInfo == nil && snapshot.sslError != nil
+        let isReachable = !snapshot.reachabilityResults.isEmpty
+            ? snapshot.reachabilityResults.contains(where: \.reachable)
+            : snapshot.reachabilityError == nil
+        let health = DomainHealth.classify(
+            certificateExpiryState: certificateExpiryState,
+            isReachable: isReachable,
+            recentMonitoringFailureCount: 0,
+            hasRecentDNSChange: changeSummary?.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) == true,
+            instabilityScore: instabilityScore,
+            hasRecentCriticalChange: changeSummary?.impactClassification == .critical,
+            hasInvalidTLS: hasInvalidTLS
+        )
+
+        return DomainReport(
             domain: snapshot.domain,
             timestamp: snapshot.timestamp,
             provenance: DomainReportProvenance(
@@ -309,6 +336,11 @@ struct DomainReportBuilder {
             riskAssessment: analysis.riskAssessment,
             insights: analysis.insights,
             changeSummary: changeSummary,
+            lastChangeDate: changeSummary?.hasChanges == true ? snapshot.timestamp : nil,
+            health: health,
+            lastMonitoringFailure: nil,
+            instabilityScore: instabilityScore,
+            certificateExpiryState: certificateExpiryState,
             workflowContext: workflowContext,
             metadata: DomainReportMetadata(
                 schemaVersion: "3.7.0",