krz/domain-dig

an ios app for DNS & SSL analysis

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

9999f876feb9a2d419a79ad601f78053c44d44f8

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-20T21:24:20Z

feat(v2.0.0): introduce tracked domains, diffing, and monitoring foundations

- replace basic watchlist with structured TrackedDomain model
- add manual refresh flow for tracked domains
- implement snapshot diffing (DNS, IP, TLS, HTTP, redirect, email, availability)
- add change summaries (changed/unchanged + affected sections)
- link tracked domains to history snapshots
- enhance HistoryEntry with normalized summary fields
- persist availability + suggestions in history and export
- add WatchlistView with pinning, notes, and status display
- introduce premium capability scaffolding (no StoreKit)
- enforce free-tier tracked domain limit (local only)
- refactor view model to support tracking, diffing, and refresh flows

notes:
- no background monitoring, notifications, or paid APIs yet
- all features remain local-first and manual
 DomainDig.xcodeproj/project.pbxproj  |   8 +-
 DomainDig/ContentView.swift          | 287 +++++++++++++++++++++++---
 DomainDig/DomainDiffService.swift    | 197 ++++++++++++++++++
 DomainDig/DomainViewModel.swift      | 381 +++++++++++++++++++++++++++++++----
 DomainDig/HistoryView.swift          |  55 ++++-
 DomainDig/Models.swift               |  75 ++++++-
 DomainDig/PremiumAccessService.swift |  27 +++
 DomainDig/WatchlistView.swift        | 316 ++++++++++++++++++++++++++---
 8 files changed, 1244 insertions(+), 102 deletions(-)

diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index b08ad23..58f2c35 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 = 12;
+				CURRENT_PROJECT_VERSION = 13;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -284,7 +284,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.9.0;
+				MARKETING_VERSION = 2.0.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 = 12;
+				CURRENT_PROJECT_VERSION = 13;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -320,7 +320,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.9.0;
+				MARKETING_VERSION = 2.0.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index f70bc6a..69f3af9 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -3,12 +3,16 @@ import SwiftUI
 
 struct ContentView: View {
     @State private var viewModel = DomainViewModel()
+    @State private var navigationPath = NavigationPath()
     @FocusState private var domainFieldFocused: Bool
     @State private var customPortInput = ""
     @State private var customPortsExpanded = false
+    @State private var trackingNoteDraft = ""
+    @State private var editingTrackedDomain: TrackedDomain?
+    @State private var showTrackLimitAlert = false
 
     var body: some View {
-        NavigationStack {
+        NavigationStack(path: $navigationPath) {
             ScrollView(.vertical) {
                 VStack(spacing: 0) {
                     inputSection
@@ -16,16 +20,42 @@ struct ContentView: View {
                         actionButtons
                         SummaryView(fields: viewModel.summaryFields)
                             .padding(.top, 8)
+                        if let changeSummary = viewModel.currentChangeSummary {
+                            DomainChangeSummaryView(summary: changeSummary)
+                                .padding(.top, 12)
+                        }
                         DomainSectionView(
                             rows: viewModel.domainRows,
                             suggestions: viewModel.suggestionRows,
                             showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
                             availabilityLoading: viewModel.availabilityLoading,
                             suggestionsLoading: viewModel.suggestionsLoading,
-                            isWatched: viewModel.isCurrentDomainWatched,
-                            onToggleWatch: { viewModel.toggleWatchedDomain() }
+                            trackedDomain: viewModel.currentTrackedDomain,
+                            trackingLimitMessage: viewModel.trackingLimitMessage,
+                            onTrack: {
+                                if !viewModel.trackCurrentDomain() {
+                                    showTrackLimitAlert = true
+                                }
+                            },
+                            onTogglePinned: {
+                                guard let trackedDomain = viewModel.currentTrackedDomain else { return }
+                                viewModel.togglePinned(for: trackedDomain)
+                            },
+                            onEditNote: {
+                                guard let trackedDomain = viewModel.currentTrackedDomain else { return }
+                                trackingNoteDraft = trackedDomain.note ?? ""
+                                editingTrackedDomain = trackedDomain
+                            }
                         )
                             .padding(.top, 16)
+                        if !viewModel.currentDiffSections.isEmpty {
+                            DomainDiffView(
+                                title: "Latest Changes",
+                                sections: viewModel.currentDiffSections,
+                                showsUnchanged: false
+                            )
+                            .padding(.top, 16)
+                        }
                         DNSSectionView(
                             dnssecLabel: viewModel.dnssecLabel,
                             sections: viewModel.dnsRows,
@@ -96,28 +126,32 @@ struct ContentView: View {
                                 .foregroundStyle(.secondary)
                         }
                     }
-                    NavigationLink {
-                        SavedDomainsView(viewModel: viewModel)
-                    } label: {
-                        Image(systemName: "bookmark")
-                            .foregroundStyle(.secondary)
-                    }
-                    NavigationLink {
-                        HistoryView(viewModel: viewModel)
-                    } label: {
-                        Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
-                            .foregroundStyle(.secondary)
-                    }
                     NavigationLink {
                         WatchlistView(viewModel: viewModel)
                     } label: {
                         Image(systemName: "eye")
                             .foregroundStyle(.secondary)
                     }
-                    NavigationLink {
-                        SettingsView()
+                    Menu {
+                        NavigationLink {
+                            HistoryView(viewModel: viewModel)
+                        } label: {
+                            Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
+                        }
+
+                        NavigationLink {
+                            SavedDomainsView(viewModel: viewModel)
+                        } label: {
+                            Label("Saved Domains", systemImage: "bookmark")
+                        }
+
+                        NavigationLink {
+                            SettingsView()
+                        } label: {
+                            Label("Settings", systemImage: "gearshape")
+                        }
                     } label: {
-                        Image(systemName: "gearshape")
+                        Image(systemName: "ellipsis.circle")
                             .foregroundStyle(.secondary)
                     }
                 }
@@ -126,6 +160,41 @@ struct ContentView: View {
         .onAppear {
             domainFieldFocused = true
         }
+        .onChange(of: viewModel.rerunNavigationToken) { _, _ in
+            navigationPath = NavigationPath()
+            domainFieldFocused = false
+        }
+        .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) {
+            Button("OK", role: .cancel) {}
+        } message: {
+            Text("Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade.")
+        }
+        .sheet(item: $editingTrackedDomain) { trackedDomain in
+            NavigationStack {
+                Form {
+                    Section("Tracking Note") {
+                        TextField("Optional note", text: $trackingNoteDraft, axis: .vertical)
+                            .lineLimit(3...6)
+                            .textInputAutocapitalization(.never)
+                            .autocorrectionDisabled()
+                    }
+                }
+                .navigationTitle(trackedDomain.domain)
+                .toolbar {
+                    ToolbarItem(placement: .cancellationAction) {
+                        Button("Cancel") {
+                            editingTrackedDomain = nil
+                        }
+                    }
+                    ToolbarItem(placement: .confirmationAction) {
+                        Button("Save") {
+                            viewModel.updateNote(trackingNoteDraft, for: trackedDomain)
+                            editingTrackedDomain = nil
+                        }
+                    }
+                }
+            }
+        }
     }
 
     private var inputSection: some View {
@@ -292,30 +361,192 @@ struct SummaryView: View {
     }
 }
 
+struct DomainChangeSummaryView: View {
+    let summary: DomainChangeSummary
+
+    var body: some View {
+        CardView(allowsHorizontalScroll: false) {
+            HStack {
+                Label(summary.hasChanges ? "Changed" : "Unchanged", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
+                    .font(.system(.caption, design: .monospaced))
+                    .foregroundStyle(summary.hasChanges ? .yellow : .green)
+                Spacer()
+                Text(summary.generatedAt, style: .time)
+                    .font(.system(.caption2, design: .monospaced))
+                    .foregroundStyle(.secondary)
+            }
+
+            Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • "))
+                .font(.system(.caption, design: .monospaced))
+                .foregroundStyle(.primary)
+        }
+    }
+}
+
+struct DomainDiffView: View {
+    let title: String
+    let sections: [DomainDiffSection]
+    let showsUnchanged: Bool
+
+    private var filteredSections: [DomainDiffSection] {
+        guard !showsUnchanged else { return sections }
+        return sections
+            .map { section in
+                DomainDiffSection(
+                    title: section.title,
+                    items: section.items.filter { $0.changeType != .unchanged }
+                )
+            }
+            .filter { !$0.items.isEmpty }
+    }
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            SectionTitleView(title: title)
+            if filteredSections.isEmpty {
+                MessageCardView(text: "No comparison data available", isError: false)
+            } else {
+                ForEach(filteredSections) { section in
+                    CardView(allowsHorizontalScroll: false) {
+                        Text(section.title)
+                            .font(.system(.subheadline, design: .monospaced))
+                            .fontWeight(.semibold)
+                            .foregroundStyle(.cyan)
+
+                        ForEach(section.items) { item in
+                            VStack(alignment: .leading, spacing: 4) {
+                                HStack {
+                                    Text(item.label)
+                                        .font(.system(.caption, design: .monospaced))
+                                        .foregroundStyle(.secondary)
+                                    Spacer()
+                                    Text(changeLabel(for: item.changeType))
+                                        .font(.system(.caption2, design: .monospaced))
+                                        .foregroundStyle(changeColor(for: item.changeType))
+                                }
+                                if let oldValue = item.oldValue {
+                                    Text("Old: \(oldValue)")
+                                        .font(.system(.caption2, design: .monospaced))
+                                        .foregroundStyle(.secondary)
+                                        .textSelection(.enabled)
+                                }
+                                if let newValue = item.newValue {
+                                    Text("New: \(newValue)")
+                                        .font(.system(.caption, design: .monospaced))
+                                        .foregroundStyle(.primary)
+                                        .textSelection(.enabled)
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private func changeLabel(for changeType: DiffChangeType) -> String {
+        switch changeType {
+        case .added:
+            return "Added"
+        case .removed:
+            return "Removed"
+        case .changed:
+            return "Changed"
+        case .unchanged:
+            return "Unchanged"
+        }
+    }
+
+    private func changeColor(for changeType: DiffChangeType) -> Color {
+        switch changeType {
+        case .added:
+            return .green
+        case .removed:
+            return .red
+        case .changed:
+            return .yellow
+        case .unchanged:
+            return .secondary
+        }
+    }
+}
+
+struct TrackedDomainDetailHeaderView: View {
+    let trackedDomain: TrackedDomain
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 4) {
+            if let note = trackedDomain.note?.nilIfEmpty {
+                LabeledValueRow(row: InfoRowViewData(label: "Tracking Note", value: note, tone: .secondary))
+            }
+            HStack(spacing: 8) {
+                if trackedDomain.isPinned {
+                    Label("Pinned", systemImage: "pin.fill")
+                }
+                Text("Last refresh \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
+            }
+            .font(.system(.caption2, design: .monospaced))
+            .foregroundStyle(.secondary)
+        }
+    }
+}
+
 struct DomainSectionView: View {
     let rows: [InfoRowViewData]
     let suggestions: [DomainSuggestionViewData]
     let showSuggestions: Bool
     let availabilityLoading: Bool
     let suggestionsLoading: Bool
-    let isWatched: Bool
-    let onToggleWatch: () -> Void
+    let trackedDomain: TrackedDomain?
+    let trackingLimitMessage: String?
+    let onTrack: () -> Void
+    let onTogglePinned: () -> Void
+    let onEditNote: (() -> Void)?
 
     var body: some View {
         VStack(alignment: .leading, spacing: 12) {
             HStack {
                 SectionTitleView(title: "Domain")
                 Spacer()
-                Button(isWatched ? "Watching" : "Watch") {
-                    onToggleWatch()
+                if let trackedDomain {
+                    HStack(spacing: 8) {
+                        Text("Tracked")
+                            .font(.system(.caption, design: .monospaced))
+                            .foregroundStyle(.green)
+                        Button {
+                            onTogglePinned()
+                        } label: {
+                            Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin")
+                        }
+                        .buttonStyle(.bordered)
+                        .font(.system(.caption, design: .monospaced))
+                        if let onEditNote {
+                            Button("Note") {
+                                onEditNote()
+                            }
+                            .buttonStyle(.bordered)
+                            .font(.system(.caption, design: .monospaced))
+                        }
+                    }
+                } else {
+                    Button("Track") {
+                        onTrack()
+                    }
+                    .buttonStyle(.bordered)
+                    .font(.system(.caption, design: .monospaced))
                 }
-                .buttonStyle(.bordered)
-                .font(.system(.caption, design: .monospaced))
             }
-            CardView {
+            CardView(allowsHorizontalScroll: false) {
                 ForEach(rows) { row in
                     LabeledValueRow(row: row)
                 }
+                if let trackedDomain {
+                    TrackedDomainDetailHeaderView(trackedDomain: trackedDomain)
+                        .padding(.top, 4)
+                } else if let trackingLimitMessage {
+                    MessageRowView(text: trackingLimitMessage, isError: false)
+                        .padding(.top, 4)
+                }
                 if availabilityLoading {
                     ProgressView("Checking availability…")
                         .appLoadingStyle()
@@ -888,6 +1119,12 @@ private extension View {
     }
 }
 
+private extension String {
+    var nilIfEmpty: String? {
+        isEmpty ? nil : self
+    }
+}
+
 private struct SettingsView: View {
     @AppStorage(DNSResolverOption.userDefaultsKey)
     private var storedResolverURL = DNSResolverOption.defaultURLString
diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift
new file mode 100644
index 0000000..fa3d8b5
--- /dev/null
+++ b/DomainDig/DomainDiffService.swift
@@ -0,0 +1,197 @@
+import Foundation
+
+enum DiffChangeType: String, Codable {
+    case added
+    case removed
+    case changed
+    case unchanged
+}
+
+struct DomainDiffItem: Identifiable, Equatable {
+    let id = UUID()
+    let label: String
+    let changeType: DiffChangeType
+    let oldValue: String?
+    let newValue: String?
+}
+
+struct DomainDiffSection: Identifiable, Equatable {
+    let id = UUID()
+    let title: String
+    let items: [DomainDiffItem]
+}
+
+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)
+            )),
+            dnsSection(from: oldSnapshot, to: newSnapshot),
+            section(title: "Redirect", item: compare(
+                label: "Final Target",
+                oldValue: finalRedirectURL(from: oldSnapshot),
+                newValue: finalRedirectURL(from: newSnapshot)
+            )),
+            tlsSection(from: oldSnapshot, to: newSnapshot),
+            httpSection(from: oldSnapshot, to: newSnapshot),
+            emailSection(from: oldSnapshot, to: newSnapshot)
+        ]
+        .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)
+
+        return DomainChangeSummary(
+            hasChanges: !changedSections.isEmpty,
+            changedSections: changedSections,
+            generatedAt: generatedAt
+        )
+    }
+
+    private static func section(title: String, item: DomainDiffItem?) -> DomainDiffSection {
+        DomainDiffSection(title: title, items: item.map { [$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))
+    }
+
+    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 item = compare(label: "Certificate", oldValue: tlsSummary(from: oldSnapshot), newValue: tlsSummary(from: newSnapshot)) {
+            items.append(item)
+        }
+        return DomainDiffSection(title: "TLS Certificate", 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 item = compare(label: "Security Grade", oldValue: normalized(oldSnapshot.httpSecurityGrade), newValue: normalized(newSnapshot.httpSecurityGrade)) {
+            items.append(item)
+        }
+        return DomainDiffSection(title: "HTTP", items: items)
+    }
+
+    private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+        section(
+            title: "Email Security",
+            item: compare(
+                label: "Summary",
+                oldValue: normalized(emailSummary(from: oldSnapshot)),
+                newValue: normalized(emailSummary(from: newSnapshot))
+            )
+        )
+    }
+
+    private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? {
+        let oldValue = normalized(oldValue)
+        let newValue = normalized(newValue)
+
+        guard oldValue != nil || newValue != nil else {
+            return nil
+        }
+
+        let changeType: DiffChangeType
+        switch (oldValue, newValue) {
+        case let (old?, new?) where old == new:
+            changeType = .unchanged
+        case (nil, _?):
+            changeType = .added
+        case (_?, nil):
+            changeType = .removed
+        default:
+            changeType = .changed
+        }
+
+        return DomainDiffItem(label: label, changeType: changeType, oldValue: oldValue, newValue: newValue)
+    }
+
+    private static func normalized(_ value: String?) -> String? {
+        guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
+            return nil
+        }
+        return value.lowercased()
+    }
+
+    private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String? {
+        switch status {
+        case .available:
+            return "available"
+        case .registered:
+            return "registered"
+        case .unknown:
+            return "unknown"
+        case .none:
+            return nil
+        }
+    }
+
+    private static func primaryIP(from snapshot: LookupSnapshot) -> String? {
+        snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
+    }
+
+    private static func finalRedirectURL(from snapshot: LookupSnapshot) -> String? {
+        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 httpStatusSummary(from snapshot: LookupSnapshot) -> String? {
+        if let httpStatusCode = snapshot.httpStatusCode {
+            return "\(httpStatusCode)"
+        }
+        return snapshot.httpHeadersError
+    }
+
+    private static func emailSummary(from snapshot: LookupSnapshot) -> String? {
+        if let emailSecurity = snapshot.emailSecurity {
+            return [
+                "spf:\(emailSecurity.spf.found)",
+                "dmarc:\(emailSecurity.dmarc.found)",
+                "dkim:\(emailSecurity.dkim.found)",
+                "bimi:\(emailSecurity.bimi.found)",
+                "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)"
+            ].joined(separator: "|")
+        }
+        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: "|")
+    }
+}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 839790b..4a20338 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -80,8 +80,10 @@ struct DomainSuggestionViewData: Identifiable {
 }
 
 struct LookupSnapshot {
+    let historyEntryID: UUID?
     let domain: String
     let timestamp: Date
+    let trackedDomainID: UUID?
     let resolverDisplayName: String
     let resolverURLString: String
     let totalLookupDurationMs: Int?
@@ -111,14 +113,17 @@ struct LookupSnapshot {
     let redirectChainError: String?
     let portScanResults: [PortScanResult]
     let portScanError: String?
+    let changeSummary: DomainChangeSummary?
     let isLive: Bool
 }
 
 extension HistoryEntry {
     var snapshot: LookupSnapshot {
         LookupSnapshot(
+            historyEntryID: id,
             domain: domain,
             timestamp: timestamp,
+            trackedDomainID: trackedDomainID,
             resolverDisplayName: resolverDisplayName,
             resolverURLString: resolverURLString,
             totalLookupDurationMs: totalLookupDurationMs,
@@ -148,6 +153,7 @@ extension HistoryEntry {
             redirectChainError: redirectChainError,
             portScanResults: portScanResults,
             portScanError: portScanError,
+            changeSummary: changeSummary,
             isLive: false
         )
     }
@@ -211,6 +217,10 @@ final class DomainViewModel {
     var hasRun = false
     private(set) var searchedDomain: String = ""
     private(set) var lastLookupDurationMs: Int?
+    private(set) var currentDiffSections: [DomainDiffSection] = []
+    private(set) var currentChangeSummary: DomainChangeSummary?
+    private(set) var refreshingTrackedDomainID: UUID?
+    private(set) var rerunNavigationToken = UUID()
 
     private var lookupTask: Task<Void, Never>?
     private var customPortScanTask: Task<Void, Never>?
@@ -224,14 +234,9 @@ final class DomainViewModel {
     private static let savedDomainsKey = "savedDomains"
     var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? []
 
-    private static let watchedDomainsKey = "watchedDomains"
-    var watchedDomains: [WatchedDomain] = {
-        guard let data = UserDefaults.standard.data(forKey: watchedDomainsKey),
-              let domains = try? JSONDecoder().decode([WatchedDomain].self, from: data) else {
-            return []
-        }
-        return domains
-    }()
+    private static let trackedDomainsKey = "trackedDomains"
+    private static let legacyWatchedDomainsKey = "watchedDomains"
+    var trackedDomains: [TrackedDomain] = DomainViewModel.loadTrackedDomains()
 
     private static let historyKey = "lookupHistory"
     private static let maxHistory = 50
@@ -276,8 +281,35 @@ final class DomainViewModel {
         !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
     }
 
-    var isCurrentDomainWatched: Bool {
-        !searchedDomain.isEmpty && watchedDomains.contains(where: { $0.domain.lowercased() == searchedDomain.lowercased() })
+    var sortedTrackedDomains: [TrackedDomain] {
+        trackedDomains.sorted {
+            if $0.isPinned != $1.isPinned {
+                return $0.isPinned && !$1.isPinned
+            }
+            if $0.updatedAt != $1.updatedAt {
+                return $0.updatedAt > $1.updatedAt
+            }
+            return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
+        }
+    }
+
+    var currentTrackedDomain: TrackedDomain? {
+        guard !searchedDomain.isEmpty else { return nil }
+        return trackedDomain(for: searchedDomain)
+    }
+
+    var isCurrentDomainTracked: Bool {
+        currentTrackedDomain != nil
+    }
+
+    var trackingLimitMessage: String? {
+        guard currentTrackedDomain == nil else { return nil }
+        guard !PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else { return nil }
+        return "Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade."
+    }
+
+    var canTrackCurrentDomain: Bool {
+        currentTrackedDomain != nil || PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count)
     }
 
     var resolverDisplayName: String {
@@ -299,8 +331,10 @@ final class DomainViewModel {
 
     var currentSnapshot: LookupSnapshot {
         LookupSnapshot(
+            historyEntryID: nil,
             domain: searchedDomain,
             timestamp: Date(),
+            trackedDomainID: currentTrackedDomain?.id,
             resolverDisplayName: resolverDisplayName,
             resolverURLString: resolverURLString,
             totalLookupDurationMs: lastLookupDurationMs,
@@ -330,6 +364,7 @@ final class DomainViewModel {
             redirectChainError: redirectChainError,
             portScanResults: allPortScanResults,
             portScanError: combinedPortScanError,
+            changeSummary: currentChangeSummary,
             isLive: true
         )
     }
@@ -408,31 +443,84 @@ final class DomainViewModel {
         UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
     }
 
-    func toggleWatchedDomain() {
-        guard !searchedDomain.isEmpty else { return }
-        toggleWatchedDomain(domain: searchedDomain, availabilityStatus: availabilityResult?.status)
+    @discardableResult
+    func trackCurrentDomain() -> Bool {
+        guard !searchedDomain.isEmpty else { return false }
+        return trackDomain(domain: searchedDomain, availabilityStatus: availabilityResult?.status)
     }
 
-    func toggleWatchedDomain(domain: String, availabilityStatus: DomainAvailabilityStatus?) {
-        guard !domain.isEmpty else { return }
+    @discardableResult
+    func trackDomain(domain: String, availabilityStatus: DomainAvailabilityStatus?) -> Bool {
+        let normalizedDomain = normalizedDomain(domain)
+        guard !normalizedDomain.isEmpty else { return false }
 
-        if watchedDomains.contains(where: { $0.domain.lowercased() == domain.lowercased() }) {
-            watchedDomains.removeAll { $0.domain.lowercased() == domain.lowercased() }
-        } else {
-            watchedDomains.insert(
-                WatchedDomain(
-                    domain: domain,
-                    lastKnownAvailability: availabilityStatus
-                ),
-                at: 0
-            )
+        if trackedDomain(for: normalizedDomain) != nil {
+            return true
         }
-        persistWatchedDomains()
+
+        guard PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else {
+            return false
+        }
+
+        trackedDomains.insert(
+            TrackedDomain(
+                domain: normalizedDomain,
+                createdAt: Date(),
+                updatedAt: Date(),
+                lastKnownAvailability: availabilityStatus
+            ),
+            at: 0
+        )
+        persistTrackedDomains()
+        linkTrackedDomainHistory(for: normalizedDomain)
+        return true
     }
 
-    func removeWatchedDomains(at offsets: IndexSet) {
-        watchedDomains.remove(atOffsets: offsets)
-        persistWatchedDomains()
+    func refreshTrackedDomain(_ trackedDomain: TrackedDomain) {
+        refreshingTrackedDomainID = trackedDomain.id
+        domain = trackedDomain.domain
+        run()
+    }
+
+    func rerunInspection(for trackedDomain: TrackedDomain) {
+        domain = trackedDomain.domain
+        run()
+        rerunNavigationToken = UUID()
+    }
+
+    func deleteTrackedDomains(at offsets: IndexSet) {
+        let ids = offsets.map { sortedTrackedDomains[$0].id }
+        trackedDomains.removeAll { ids.contains($0.id) }
+        history.indices.forEach { index in
+            if let trackedDomainID = history[index].trackedDomainID, ids.contains(trackedDomainID) {
+                history[index].trackedDomainID = nil
+            }
+        }
+        persistTrackedDomains()
+        persistHistory()
+    }
+
+    func deleteTrackedDomain(_ trackedDomain: TrackedDomain) {
+        trackedDomains.removeAll { $0.id == trackedDomain.id }
+        history.indices.forEach { index in
+            if history[index].trackedDomainID == trackedDomain.id {
+                history[index].trackedDomainID = nil
+            }
+        }
+        persistTrackedDomains()
+        persistHistory()
+    }
+
+    func togglePinned(for trackedDomain: TrackedDomain) {
+        guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
+        trackedDomains[index].isPinned.toggle()
+        persistTrackedDomains()
+    }
+
+    func updateNote(_ note: String, for trackedDomain: TrackedDomain) {
+        guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
+        trackedDomains[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+        persistTrackedDomains()
     }
 
     func removeHistoryEntries(at offsets: IndexSet) {
@@ -440,6 +528,11 @@ final class DomainViewModel {
         persistHistory()
     }
 
+    func clearHistory() {
+        history.removeAll()
+        persistHistory()
+    }
+
     func clearRecentSearches() {
         recentSearches.removeAll()
         UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
@@ -449,6 +542,7 @@ final class DomainViewModel {
         UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey)
         domain = entry.domain
         run()
+        rerunNavigationToken = UUID()
     }
 
     func reset() {
@@ -457,6 +551,9 @@ final class DomainViewModel {
         hasRun = false
         searchedDomain = ""
         lastLookupDurationMs = nil
+        currentDiffSections = []
+        currentChangeSummary = nil
+        refreshingTrackedDomainID = nil
         clearLookupState()
     }
 
@@ -474,6 +571,8 @@ final class DomainViewModel {
         addRecentSearch(target)
         searchedDomain = target
         hasRun = true
+        currentDiffSections = []
+        currentChangeSummary = nil
         clearLookupState()
         setAllLoadingStates(true)
         customPortScanLoading = false
@@ -513,7 +612,12 @@ final class DomainViewModel {
     }
 
     func exportText() -> String {
-        Self.formatExportText(from: currentSnapshot)
+        Self.formatExportText(
+            from: currentSnapshot,
+            trackedDomain: currentTrackedDomain,
+            changeSummary: currentChangeSummary,
+            diffSections: currentDiffSections
+        )
     }
 
     private func performLookup(domain: String, lookupID: UUID) async {
@@ -555,6 +659,7 @@ final class DomainViewModel {
         guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
         lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) }
         saveHistoryEntry(replaceLatest: false)
+        refreshingTrackedDomainID = nil
     }
 
     private func runDNS(domain: String, lookupID: UUID) async {
@@ -579,7 +684,7 @@ final class DomainViewModel {
         guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
         availabilityResult = result
         availabilityLoading = false
-        updateWatchedDomainAvailability(for: result.domain, status: result.status)
+        updateTrackedDomainAvailability(for: result.domain, status: result.status)
     }
 
     private func runSSL(domain: String, lookupID: UUID) async {
@@ -801,9 +906,20 @@ final class DomainViewModel {
 
     private func saveHistoryEntry(replaceLatest: Bool) {
         guard !searchedDomain.isEmpty else { return }
+
+        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) }
+
+        currentChangeSummary = changeSummary
+        currentDiffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []
+
         let entry = HistoryEntry(
             domain: searchedDomain,
-            timestamp: Date(),
+            timestamp: timestamp,
+            trackedDomainID: trackedDomainID,
             dnsSections: dnsSections,
             sslInfo: sslInfo,
             httpHeaders: httpHeaders,
@@ -820,6 +936,12 @@ final class DomainViewModel {
             resolverDisplayName: resolverDisplayName,
             resolverURLString: resolverURLString,
             totalLookupDurationMs: lastLookupDurationMs,
+            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,
@@ -838,6 +960,14 @@ final class DomainViewModel {
                 history = Array(history.prefix(Self.maxHistory))
             }
         }
+
+        updateTrackedDomainSnapshotMetadata(
+            domain: searchedDomain,
+            snapshotID: entry.id,
+            availabilityStatus: availabilityResult?.status,
+            updatedAt: timestamp,
+            changeSummary: changeSummary
+        )
         persistHistory()
     }
 
@@ -847,18 +977,82 @@ final class DomainViewModel {
         }
     }
 
-    private func persistWatchedDomains() {
-        if let data = try? JSONEncoder().encode(watchedDomains) {
-            UserDefaults.standard.set(data, forKey: Self.watchedDomainsKey)
+    private func persistTrackedDomains() {
+        if let data = try? JSONEncoder().encode(trackedDomains) {
+            UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey)
         }
     }
 
-    private func updateWatchedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
-        guard let index = watchedDomains.firstIndex(where: { $0.domain.lowercased() == domain.lowercased() }) else {
+    private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
+        guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
             return
         }
-        watchedDomains[index].lastKnownAvailability = status
-        persistWatchedDomains()
+        trackedDomains[index].lastKnownAvailability = status
+        persistTrackedDomains()
+    }
+
+    private func updateTrackedDomainSnapshotMetadata(
+        domain: String,
+        snapshotID: UUID,
+        availabilityStatus: DomainAvailabilityStatus?,
+        updatedAt: Date,
+        changeSummary: DomainChangeSummary?
+    ) {
+        guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
+            return
+        }
+        trackedDomains[index].lastSnapshotID = snapshotID
+        trackedDomains[index].lastKnownAvailability = availabilityStatus
+        trackedDomains[index].updatedAt = updatedAt
+        trackedDomains[index].lastChangeSummary = changeSummary
+        persistTrackedDomains()
+    }
+
+    private func previousSnapshot(for domain: String, trackedDomainID: UUID?, replacingLatest: Bool) -> LookupSnapshot? {
+        let matchingEntries = history.filter { entry in
+            if let trackedDomainID {
+                return entry.trackedDomainID == trackedDomainID
+            }
+            return entry.domain.caseInsensitiveCompare(domain) == .orderedSame
+        }
+
+        if replacingLatest {
+            return matchingEntries.dropFirst().first?.snapshot
+        }
+        return matchingEntries.first?.snapshot
+    }
+
+    private func trackedDomain(for domain: String) -> TrackedDomain? {
+        trackedDomains.first { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
+    }
+
+    private func normalizedDomain(_ domain: String) -> String {
+        domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+    }
+
+    private func linkTrackedDomainHistory(for domain: String) {
+        guard let trackedDomain = trackedDomain(for: domain) else { return }
+        var didChange = false
+
+        for index in history.indices where history[index].domain.caseInsensitiveCompare(domain) == .orderedSame {
+            if history[index].trackedDomainID != trackedDomain.id {
+                history[index].trackedDomainID = trackedDomain.id
+                didChange = true
+            }
+        }
+
+        if didChange {
+            persistHistory()
+        }
+
+        if let latestEntry = history.first(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }),
+           let trackedIndex = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
+            trackedDomains[trackedIndex].lastSnapshotID = latestEntry.id
+            trackedDomains[trackedIndex].lastChangeSummary = latestEntry.changeSummary
+            trackedDomains[trackedIndex].lastKnownAvailability = latestEntry.availabilityResult?.status
+            trackedDomains[trackedIndex].updatedAt = latestEntry.timestamp
+            persistTrackedDomains()
+        }
     }
 
     private func addRecentSearch(_ domain: String) {
@@ -937,6 +1131,71 @@ final class DomainViewModel {
         activeLookupID == lookupID
     }
 
+    func recentSnapshots(for trackedDomain: TrackedDomain, limit: Int = 6) -> [HistoryEntry] {
+        history
+            .filter { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }
+            .sorted { $0.timestamp > $1.timestamp }
+            .prefix(limit)
+            .map { $0 }
+    }
+
+    func diffSectionsForLatestSnapshots(of trackedDomain: TrackedDomain) -> [DomainDiffSection] {
+        let snapshots = recentSnapshots(for: trackedDomain, limit: 2)
+        guard snapshots.count == 2 else { return [] }
+        return DomainDiffService.diff(from: snapshots[1].snapshot, to: snapshots[0].snapshot)
+    }
+
+    func latestChangeSummary(for trackedDomain: TrackedDomain) -> DomainChangeSummary? {
+        trackedDomain.lastChangeSummary ?? recentSnapshots(for: trackedDomain, limit: 1).first?.changeSummary
+    }
+
+    func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? {
+        let siblings = history.filter { candidate in
+            if let trackedDomainID = entry.trackedDomainID {
+                return candidate.trackedDomainID == trackedDomainID && candidate.id != entry.id
+            }
+            return candidate.domain.caseInsensitiveCompare(entry.domain) == .orderedSame && candidate.id != entry.id
+        }
+        .sorted { $0.timestamp > $1.timestamp }
+
+        return siblings.first?.snapshot
+    }
+
+    private static func loadTrackedDomains() -> [TrackedDomain] {
+        let defaults = UserDefaults.standard
+        let decoder = JSONDecoder()
+
+        if let data = defaults.data(forKey: trackedDomainsKey),
+           let domains = try? decoder.decode([TrackedDomain].self, from: data) {
+            return deduplicatedTrackedDomains(domains)
+        }
+
+        if let legacyData = defaults.data(forKey: legacyWatchedDomainsKey),
+           let legacyDomains = try? decoder.decode([WatchedDomain].self, from: legacyData) {
+            return deduplicatedTrackedDomains(
+                legacyDomains.map {
+                    TrackedDomain(
+                        id: $0.id,
+                        domain: $0.domain.lowercased(),
+                        createdAt: $0.createdAt,
+                        updatedAt: $0.createdAt,
+                        lastKnownAvailability: $0.lastKnownAvailability
+                    )
+                }
+            )
+        }
+
+        return []
+    }
+
+    private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] {
+        var seen = Set<String>()
+        return domains.filter { domain in
+            let key = domain.domain.lowercased()
+            return seen.insert(key).inserted
+        }
+    }
+
     static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] {
         [
             SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary),
@@ -1110,7 +1369,12 @@ final class DomainViewModel {
             }
     }
 
-    static func formatExportText(from snapshot: LookupSnapshot) -> String {
+    static func formatExportText(
+        from snapshot: LookupSnapshot,
+        trackedDomain: TrackedDomain?,
+        changeSummary: DomainChangeSummary?,
+        diffSections: [DomainDiffSection]
+    ) -> String {
         let exportDateFormatter = DateFormatter()
         exportDateFormatter.dateFormat = "yyyy-MM-dd HH:mm"
 
@@ -1120,9 +1384,14 @@ final class DomainViewModel {
             "Date: \(exportDateFormatter.string(from: snapshot.timestamp))",
             "Mode: \(snapshot.isLive ? "Live" : "Snapshot")",
             "Resolver: \(snapshot.resolverDisplayName)",
-            "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))"
+            "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))",
+            "Tracked: \(trackedDomain == nil ? "No" : "Yes")"
         ]
 
+        if let note = trackedDomain?.note?.nilIfEmpty {
+            lines.append("Tracking Note: \(note)")
+        }
+
         func appendSection(_ title: String, body: () -> Void) {
             lines.append("")
             lines.append(title)
@@ -1134,6 +1403,36 @@ final class DomainViewModel {
             for item in summaryFields(from: snapshot) {
                 lines.append("  \(item.label): \(item.value)")
             }
+            if let changeSummary {
+                lines.append("  Change Status: \(changeSummary.hasChanges ? "Changed" : "Unchanged")")
+                lines.append("  Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))")
+            }
+        }
+
+        appendSection("Tracking") {
+            if let trackedDomain {
+                lines.append("  Pinned: \(trackedDomain.isPinned ? "Yes" : "No")")
+                lines.append("  Last Refresh: \(exportDateFormatter.string(from: trackedDomain.updatedAt))")
+                lines.append("  Last Known Availability: \(availabilityLabel(trackedDomain.lastKnownAvailability))")
+                if let note = trackedDomain.note?.nilIfEmpty {
+                    lines.append("  Note: \(note)")
+                }
+            } else {
+                lines.append("  This domain is not currently tracked.")
+            }
+        }
+
+        appendSection("Diff Summary") {
+            if diffSections.isEmpty {
+                lines.append("  No comparison available")
+            } else {
+                for section in diffSections where section.items.contains(where: { $0.changeType != .unchanged }) {
+                    lines.append("  \(section.title)")
+                    for item in section.items where item.changeType != .unchanged {
+                        lines.append("    \(item.label): \(item.oldValue ?? "None") -> \(item.newValue ?? "None")")
+                    }
+                }
+            }
         }
 
         appendSection("Domain") {
diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift
index 5bbbb91..bb9d255 100644
--- a/DomainDig/HistoryView.swift
+++ b/DomainDig/HistoryView.swift
@@ -2,6 +2,8 @@ import SwiftUI
 
 struct HistoryView: View {
     @Bindable var viewModel: DomainViewModel
+    @Environment(\.dismiss) private var dismiss
+    @State private var showClearAllConfirmation = false
 
     private let dateFormatter: DateFormatter = {
         let formatter = DateFormatter()
@@ -50,8 +52,29 @@ struct HistoryView: View {
         .navigationTitle("History")
         .toolbar {
             if !viewModel.history.isEmpty {
-                EditButton()
+                ToolbarItemGroup(placement: .topBarTrailing) {
+                    Menu {
+                        Button("Clear All", role: .destructive) {
+                            showClearAllConfirmation = true
+                        }
+                    } label: {
+                        Image(systemName: "ellipsis.circle")
+                    }
+
+                    EditButton()
+                }
+            }
+        }
+        .alert("Clear history?", isPresented: $showClearAllConfirmation) {
+            Button("Clear All", role: .destructive) {
+                viewModel.clearHistory()
             }
+            Button("Cancel", role: .cancel) {}
+        } message: {
+            Text("This will delete all saved history entries. This cannot be undone.")
+        }
+        .onChange(of: viewModel.rerunNavigationToken) { _, _ in
+            dismiss()
         }
         .preferredColorScheme(.dark)
     }
@@ -60,6 +83,7 @@ struct HistoryView: View {
 struct HistoryDetailView: View {
     @Bindable var viewModel: DomainViewModel
     let entry: HistoryEntry
+    @Environment(\.dismiss) private var dismiss
 
     private let dateFormatter: DateFormatter = {
         let formatter = DateFormatter()
@@ -84,12 +108,30 @@ struct HistoryDetailView: View {
                     showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty,
                     availabilityLoading: false,
                     suggestionsLoading: false,
-                    isWatched: viewModel.watchedDomains.contains(where: { $0.domain.lowercased() == entry.domain.lowercased() }),
-                    onToggleWatch: {
-                        viewModel.toggleWatchedDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status)
-                    }
+                    trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }),
+                    trackingLimitMessage: nil,
+                    onTrack: {
+                        _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status)
+                    },
+                    onTogglePinned: {
+                        guard let trackedDomain = viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }) else { return }
+                        viewModel.togglePinned(for: trackedDomain)
+                    },
+                    onEditNote: nil
                 )
                     .padding(.top, 16)
+                if let comparisonSnapshot = viewModel.comparisonSnapshot(for: entry) {
+                    if let changeSummary = entry.changeSummary {
+                        DomainChangeSummaryView(summary: changeSummary)
+                            .padding(.top, 16)
+                    }
+                    DomainDiffView(
+                        title: "Compared With Previous Snapshot",
+                        sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot),
+                        showsUnchanged: false
+                    )
+                    .padding(.top, 16)
+                }
                 DNSSectionView(
                     dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot),
                     sections: DomainViewModel.dnsRows(from: snapshot),
@@ -150,6 +192,9 @@ struct HistoryDetailView: View {
                 viewModel.rerunLookup(from: entry)
             }
         }
+        .onChange(of: viewModel.rerunNavigationToken) { _, _ in
+            dismiss()
+        }
         .preferredColorScheme(.dark)
     }
 
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index 5a836ba..e571da9 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -48,6 +48,54 @@ struct WatchedDomain: Codable, Identifiable {
     }
 }
 
+struct DomainChangeSummary: Codable, Equatable {
+    let hasChanges: Bool
+    let changedSections: [String]
+    let generatedAt: Date
+}
+
+enum PremiumCapability: String, Codable {
+    case unlimitedTrackedDomains
+    case automatedMonitoring
+    case pushAlerts
+    case batchTracking
+    case advancedExports
+}
+
+struct TrackedDomain: Codable, Identifiable, Equatable {
+    let id: UUID
+    var domain: String
+    var createdAt: Date
+    var updatedAt: Date
+    var note: String?
+    var isPinned: Bool
+    var lastKnownAvailability: DomainAvailabilityStatus?
+    var lastSnapshotID: UUID?
+    var lastChangeSummary: DomainChangeSummary?
+
+    init(
+        id: UUID = UUID(),
+        domain: String,
+        createdAt: Date = Date(),
+        updatedAt: Date = Date(),
+        note: String? = nil,
+        isPinned: Bool = false,
+        lastKnownAvailability: DomainAvailabilityStatus? = nil,
+        lastSnapshotID: UUID? = nil,
+        lastChangeSummary: DomainChangeSummary? = nil
+    ) {
+        self.id = id
+        self.domain = domain
+        self.createdAt = createdAt
+        self.updatedAt = updatedAt
+        self.note = note
+        self.isPinned = isPinned
+        self.lastKnownAvailability = lastKnownAvailability
+        self.lastSnapshotID = lastSnapshotID
+        self.lastChangeSummary = lastChangeSummary
+    }
+}
+
 // MARK: - DNS Models
 
 enum DNSRecordType: String, CaseIterable, Codable {
@@ -321,6 +369,7 @@ struct HistoryEntry: Identifiable, Codable {
     var id = UUID()
     let domain: String
     let timestamp: Date
+    var trackedDomainID: UUID?
     let dnsSections: [DNSSection]
     let sslInfo: SSLCertificateInfo?
     let httpHeaders: [HTTPHeader]
@@ -337,6 +386,12 @@ struct HistoryEntry: Identifiable, Codable {
     var resolverDisplayName: String
     var resolverURLString: String
     var totalLookupDurationMs: Int?
+    var primaryIP: String?
+    var finalRedirectURL: String?
+    var tlsStatusSummary: String?
+    var emailSecuritySummary: String?
+    var httpGradeSummary: String?
+    var changeSummary: DomainChangeSummary?
     var sslError: String?
     var httpHeadersError: String?
     var reachabilityError: String?
@@ -346,19 +401,22 @@ struct HistoryEntry: Identifiable, Codable {
     var redirectChainError: String?
     var portScanError: String?
 
-    init(domain: String, timestamp: Date, dnsSections: [DNSSection],
+    init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, dnsSections: [DNSSection],
          sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
          reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
          emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil,
          redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [],
          hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil,
          suggestions: [DomainSuggestionResult] = [], resolverDisplayName: String, resolverURLString: String,
-         totalLookupDurationMs: Int? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
+         totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil,
+         tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
+         changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
          reachabilityError: String? = nil, ipGeolocationError: String? = nil,
          emailSecurityError: String? = nil, ptrError: String? = nil,
          redirectChainError: String? = nil, portScanError: String? = nil) {
         self.domain = domain
         self.timestamp = timestamp
+        self.trackedDomainID = trackedDomainID
         self.dnsSections = dnsSections
         self.sslInfo = sslInfo
         self.httpHeaders = httpHeaders
@@ -375,6 +433,12 @@ struct HistoryEntry: Identifiable, Codable {
         self.resolverDisplayName = resolverDisplayName
         self.resolverURLString = resolverURLString
         self.totalLookupDurationMs = totalLookupDurationMs
+        self.primaryIP = primaryIP
+        self.finalRedirectURL = finalRedirectURL
+        self.tlsStatusSummary = tlsStatusSummary
+        self.emailSecuritySummary = emailSecuritySummary
+        self.httpGradeSummary = httpGradeSummary
+        self.changeSummary = changeSummary
         self.sslError = sslError
         self.httpHeadersError = httpHeadersError
         self.reachabilityError = reachabilityError
@@ -390,6 +454,7 @@ struct HistoryEntry: Identifiable, Codable {
         id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
         domain = try container.decode(String.self, forKey: .domain)
         timestamp = try container.decode(Date.self, forKey: .timestamp)
+        trackedDomainID = try container.decodeIfPresent(UUID.self, forKey: .trackedDomainID)
         dnsSections = try container.decode([DNSSection].self, forKey: .dnsSections)
         sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo)
         httpHeaders = try container.decode([HTTPHeader].self, forKey: .httpHeaders)
@@ -406,6 +471,12 @@ struct HistoryEntry: Identifiable, Codable {
         resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare"
         resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString
         totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs)
+        primaryIP = try container.decodeIfPresent(String.self, forKey: .primaryIP)
+        finalRedirectURL = try container.decodeIfPresent(String.self, forKey: .finalRedirectURL)
+        tlsStatusSummary = try container.decodeIfPresent(String.self, forKey: .tlsStatusSummary)
+        emailSecuritySummary = try container.decodeIfPresent(String.self, forKey: .emailSecuritySummary)
+        httpGradeSummary = try container.decodeIfPresent(String.self, forKey: .httpGradeSummary)
+        changeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .changeSummary)
         sslError = try container.decodeIfPresent(String.self, forKey: .sslError)
         httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError)
         reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError)
diff --git a/DomainDig/PremiumAccessService.swift b/DomainDig/PremiumAccessService.swift
new file mode 100644
index 0000000..698a2d2
--- /dev/null
+++ b/DomainDig/PremiumAccessService.swift
@@ -0,0 +1,27 @@
+import Foundation
+
+enum PremiumAccessService {
+    static let freeTrackedDomainLimit = 3
+
+    static func hasAccess(to capability: PremiumCapability) -> Bool {
+        switch capability {
+        case .unlimitedTrackedDomains,
+             .automatedMonitoring,
+             .pushAlerts,
+             .batchTracking,
+             .advancedExports:
+            return false
+        }
+    }
+
+    static func trackedDomainLimitMessage(currentCount: Int) -> String? {
+        guard currentCount >= freeTrackedDomainLimit, !hasAccess(to: .unlimitedTrackedDomains) else {
+            return nil
+        }
+        return "More tracked domains will be available in a future Pro upgrade."
+    }
+
+    static func canAddTrackedDomain(currentCount: Int) -> Bool {
+        hasAccess(to: .unlimitedTrackedDomains) || currentCount < freeTrackedDomainLimit
+    }
+}
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index c44fc65..aea8707 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -6,31 +6,86 @@ struct WatchlistView: View {
 
     var body: some View {
         List {
-            if viewModel.watchedDomains.isEmpty {
-                Text("No watched domains")
-                    .font(.system(.callout, design: .monospaced))
-                    .foregroundStyle(.secondary)
-                    .listRowBackground(Color(.systemGray6).opacity(0.5))
+            if viewModel.sortedTrackedDomains.isEmpty {
+                Section {
+                    VStack(alignment: .leading, spacing: 8) {
+                        Text("No tracked domains yet")
+                            .font(.system(.callout, design: .monospaced))
+                            .foregroundStyle(.primary)
+                        Text("Tracked domains appear here. Tracking is local and manual for now.")
+                            .font(.system(.caption, design: .monospaced))
+                            .foregroundStyle(.secondary)
+                    }
+                    .padding(.vertical, 8)
+                }
+                .listRowBackground(Color(.systemGray6).opacity(0.5))
             } else {
-                ForEach(viewModel.watchedDomains) { watchedDomain in
-                    Button {
-                        viewModel.domain = watchedDomain.domain
-                        dismiss()
-                        viewModel.run()
-                    } label: {
-                        VStack(alignment: .leading, spacing: 4) {
-                            Text(watchedDomain.domain)
-                                .font(.system(.callout, design: .monospaced))
-                                .foregroundStyle(.primary)
-                            Text(statusLabel(watchedDomain.lastKnownAvailability))
-                                .font(.system(.caption2, design: .monospaced))
-                                .foregroundStyle(statusColor(watchedDomain.lastKnownAvailability))
-                        }
+                if let limitMessage = PremiumAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) {
+                    Section {
+                        Text(limitMessage)
+                            .font(.system(.caption, design: .monospaced))
+                            .foregroundStyle(.secondary)
                     }
                     .listRowBackground(Color(.systemGray6).opacity(0.5))
                 }
-                .onDelete { offsets in
-                    viewModel.removeWatchedDomains(at: offsets)
+
+                Section {
+                    ForEach(viewModel.sortedTrackedDomains) { trackedDomain in
+                        NavigationLink {
+                            TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
+                        } label: {
+                            WatchlistRowView(
+                                trackedDomain: trackedDomain,
+                                isRefreshing: viewModel.refreshingTrackedDomainID == trackedDomain.id
+                            )
+                        }
+                        .buttonStyle(.plain)
+                        .swipeActions(edge: .leading, allowsFullSwipe: false) {
+                            Button {
+                                viewModel.togglePinned(for: trackedDomain)
+                            } label: {
+                                Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
+                            }
+                            .tint(.yellow)
+                        }
+                        .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+                            Button(role: .destructive) {
+                                viewModel.deleteTrackedDomain(trackedDomain)
+                            } label: {
+                                Label("Delete", systemImage: "trash")
+                            }
+                        }
+                        .contextMenu {
+                            Button {
+                                viewModel.refreshTrackedDomain(trackedDomain)
+                            } label: {
+                                Label("Refresh", systemImage: "arrow.clockwise")
+                            }
+
+                            Button {
+                                dismiss()
+                                viewModel.rerunInspection(for: trackedDomain)
+                            } label: {
+                                Label("Open Inspection", systemImage: "magnifyingglass")
+                            }
+
+                            Button {
+                                viewModel.togglePinned(for: trackedDomain)
+                            } label: {
+                                Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
+                            }
+
+                            Button(role: .destructive) {
+                                viewModel.deleteTrackedDomain(trackedDomain)
+                            } label: {
+                                Label("Delete", systemImage: "trash")
+                            }
+                        }
+                        .listRowBackground(Color(.systemGray6).opacity(0.5))
+                    }
+                    .onDelete(perform: viewModel.deleteTrackedDomains)
+                } header: {
+                    Text("Tracked Domains")
                 }
             }
         }
@@ -38,14 +93,59 @@ struct WatchlistView: View {
         .background(Color.black)
         .navigationTitle("Watchlist")
         .toolbar {
-            if !viewModel.watchedDomains.isEmpty {
+            if !viewModel.sortedTrackedDomains.isEmpty {
                 EditButton()
             }
         }
+        .onChange(of: viewModel.rerunNavigationToken) { _, _ in
+            dismiss()
+        }
         .preferredColorScheme(.dark)
     }
+}
+
+struct WatchlistRowView: View {
+    let trackedDomain: TrackedDomain
+    let isRefreshing: Bool
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 6) {
+            HStack(alignment: .firstTextBaseline, spacing: 8) {
+                if trackedDomain.isPinned {
+                    Image(systemName: "pin.fill")
+                        .font(.caption2)
+                        .foregroundStyle(.yellow)
+                }
+                Text(trackedDomain.domain)
+                    .font(.system(.callout, design: .monospaced))
+                    .foregroundStyle(.primary)
+                    .lineLimit(2)
+                    .multilineTextAlignment(.leading)
+                Spacer(minLength: 8)
+                statusBadge
+            }
+
+            Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
+                .font(.system(.caption2, design: .monospaced))
+                .foregroundStyle(.secondary)
+
+            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: " • "))
+                    .font(.system(.caption, design: .monospaced))
+                    .foregroundStyle(.secondary)
+                    .lineLimit(2)
+            }
+        }
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .padding(.vertical, 4)
+    }
 
-    private func statusLabel(_ status: DomainAvailabilityStatus?) -> String {
+    private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
         switch status {
         case .available:
             return "Available"
@@ -56,8 +156,33 @@ struct WatchlistView: View {
         }
     }
 
-    private func statusColor(_ status: DomainAvailabilityStatus?) -> Color {
-        switch status {
+    @ViewBuilder
+    private var statusBadge: some View {
+        if isRefreshing {
+            HStack(spacing: 6) {
+                ProgressView()
+                    .controlSize(.small)
+                Text("Refreshing")
+                    .font(.system(.caption2, design: .monospaced))
+                    .foregroundStyle(.secondary)
+            }
+            .padding(.horizontal, 8)
+            .padding(.vertical, 4)
+            .background(Color(.systemGray5).opacity(0.6))
+            .clipShape(Capsule())
+        } else {
+            Text(availabilityLabel(trackedDomain.lastKnownAvailability))
+                .font(.system(.caption2, design: .monospaced))
+                .foregroundStyle(badgeColor)
+                .padding(.horizontal, 8)
+                .padding(.vertical, 4)
+                .background(badgeBackground)
+                .clipShape(Capsule())
+        }
+    }
+
+    private var badgeColor: Color {
+        switch trackedDomain.lastKnownAvailability {
         case .available:
             return .green
         case .registered:
@@ -66,4 +191,145 @@ struct WatchlistView: View {
             return .secondary
         }
     }
+
+    private var badgeBackground: Color {
+        switch trackedDomain.lastKnownAvailability {
+        case .available:
+            return .green.opacity(0.16)
+        case .registered:
+            return .yellow.opacity(0.16)
+        case .unknown, .none:
+            return Color(.systemGray5).opacity(0.6)
+        }
+    }
+}
+
+struct TrackedDomainDetailView: View {
+    @Bindable var viewModel: DomainViewModel
+    let trackedDomain: TrackedDomain
+    @Environment(\.dismiss) private var dismiss
+
+    @State private var noteDraft = ""
+    @State private var isEditingNote = false
+
+    private var liveTrackedDomain: TrackedDomain {
+        viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain
+    }
+
+    private var latestSnapshots: [HistoryEntry] {
+        viewModel.recentSnapshots(for: liveTrackedDomain)
+    }
+
+    private var latestDiffSections: [DomainDiffSection] {
+        viewModel.diffSectionsForLatestSnapshots(of: liveTrackedDomain)
+    }
+
+    var body: some View {
+        List {
+            Section {
+                WatchlistRowView(
+                    trackedDomain: liveTrackedDomain,
+                    isRefreshing: viewModel.refreshingTrackedDomainID == liveTrackedDomain.id
+                )
+            }
+            .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+            Section {
+                Button {
+                    viewModel.refreshTrackedDomain(liveTrackedDomain)
+                } label: {
+                    Label("Manual Refresh", systemImage: "arrow.clockwise")
+                }
+
+                Button {
+                    viewModel.rerunInspection(for: liveTrackedDomain)
+                } label: {
+                    Label("Re-run Inspection", systemImage: "magnifyingglass")
+                }
+
+                Button {
+                    viewModel.togglePinned(for: liveTrackedDomain)
+                } label: {
+                    Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin")
+                }
+
+                Button {
+                    noteDraft = liveTrackedDomain.note ?? ""
+                    isEditingNote = true
+                } label: {
+                    Label(liveTrackedDomain.note == nil ? "Add Note" : "Edit Note", systemImage: "note.text")
+                }
+            }
+            .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+            if let summary = viewModel.latestChangeSummary(for: liveTrackedDomain) {
+                Section("Latest Change Summary") {
+                    DomainChangeSummaryView(summary: summary)
+                }
+                .listRowBackground(Color.clear)
+            }
+
+            if !latestDiffSections.isEmpty {
+                Section("Latest Diff") {
+                    DomainDiffView(title: "Latest Snapshot vs Previous", sections: latestDiffSections, showsUnchanged: false)
+                }
+                .listRowBackground(Color.clear)
+            }
+
+            Section("Recent Snapshots") {
+                if latestSnapshots.isEmpty {
+                    Text("No snapshots yet")
+                        .font(.system(.caption, design: .monospaced))
+                        .foregroundStyle(.secondary)
+                } else {
+                    ForEach(latestSnapshots) { entry in
+                        NavigationLink {
+                            HistoryDetailView(viewModel: viewModel, entry: entry)
+                        } label: {
+                            VStack(alignment: .leading, spacing: 4) {
+                                Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened))
+                                    .font(.system(.caption, design: .monospaced))
+                                    .foregroundStyle(.primary)
+                                Text(entry.changeSummary?.hasChanges == true ? "Changed" : "Snapshot")
+                                    .font(.system(.caption2, design: .monospaced))
+                                    .foregroundStyle(.secondary)
+                            }
+                        }
+                    }
+                }
+            }
+            .listRowBackground(Color(.systemGray6).opacity(0.5))
+        }
+        .scrollContentBackground(.hidden)
+        .background(Color.black)
+        .navigationTitle(liveTrackedDomain.domain)
+        .preferredColorScheme(.dark)
+        .onChange(of: viewModel.rerunNavigationToken) { _, _ in
+            dismiss()
+        }
+        .sheet(isPresented: $isEditingNote) {
+            NavigationStack {
+                Form {
+                    Section("Tracking Note") {
+                        TextField("Optional note", text: $noteDraft, axis: .vertical)
+                            .lineLimit(3...6)
+                    }
+                }
+                .navigationTitle("Edit Note")
+                .toolbar {
+                    ToolbarItem(placement: .cancellationAction) {
+                        Button("Cancel") {
+                            isEditingNote = false
+                        }
+                    }
+                    ToolbarItem(placement: .confirmationAction) {
+                        Button("Save") {
+                            viewModel.updateNote(noteDraft, for: liveTrackedDomain)
+                            isEditingNote = false
+                        }
+                    }
+                }
+            }
+        }
+    }
 }