Commit d624802338

d62480233819184db2e55741b05375818ebf3881

parent: 6f79345f8d

Unsigned

cmc <hello@cleberg.net> · 2026-07-17 15:25 UTC

v4.7.0: Add domain-vs-domain comparison

- DiffService.compare(domainA:domainB:) reuses the existing section-diff
  builders to compare two distinct domains' latest reports, returning a new
  DomainComparisonResult (parallel to the time-based DomainDiff).
- DomainCompareView: pick two tracked domains and render the comparison with
  the existing DomainDiffView section renderer.
- Entry point: "Compare Domains" in the Watchlist toolbar menu, shown once at
  least two domains are tracked.

Layout: unified · split

DomainDig/DiffService.swift +51
@@ -105,6 +105,32 @@ struct DomainDiff: Identifiable, Equatable, Codable {
105105typealias DomainDiffItem = DiffItem
106106typealias DomainDiffSection = DiffSection
107107
108/// Result of comparing two distinct domains' latest reports side by side, as
109/// opposed to `DomainDiff`, which compares the same domain across time.
110struct DomainComparisonResult: Identifiable, Equatable {
111 let domainA: String
112 let domainB: String
113 let generatedAt: Date
114 let sections: [DiffSection]
115 let contextNote: String?
116
117 var id: String {
118 "\(domainA)-\(domainB)-\(generatedAt.timeIntervalSince1970)"
119 }
120
121 var changedSections: [DiffSection] {
122 sections.filter(\.hasChanges)
123 }
124
125 var changeCount: Int {
126 sections.reduce(0) { $0 + $1.changeCount }
127 }
128
129 var severity: ChangeSeverity {
130 sections.map(\.severity).max() ?? .low
131 }
132}
133
108134enum DiffService {
109135 static func compare(from oldReport: DomainReport, to newReport: DomainReport) -> DomainDiff {
110136 let sections = [
@@ -181,6 +207,31 @@ enum DiffService {
181207 )
182208 }
183209
210 /// Compares two distinct domains' reports section by section. Reuses the same
211 /// field-level diff logic as time-based comparison; the two reports are simply
212 /// unrelated domains rather than the same domain at different times.
213 static func compare(domainA: DomainReport, domainB: DomainReport) -> DomainComparisonResult {
214 let sections = [
215 availabilitySection(from: domainA, to: domainB),
216 ownershipSection(from: domainA, to: domainB),
217 dnsSection(from: domainA, to: domainB),
218 webSection(from: domainA, to: domainB),
219 emailSection(from: domainA, to: domainB),
220 networkSection(from: domainA, to: domainB),
221 subdomainsSection(from: domainA, to: domainB),
222 intelligenceSection(from: domainA, to: domainB),
223 riskSection(from: domainA, to: domainB)
224 ]
225
226 return DomainComparisonResult(
227 domainA: domainA.domain,
228 domainB: domainB.domain,
229 generatedAt: Date(),
230 sections: sections,
231 contextNote: comparisonContextNote(from: domainA, to: domainB)
232 )
233 }
234
184235 static func comparisonContextNote(from oldReport: DomainReport, to newReport: DomainReport) -> String? {
185236 var notes: [String] = []
186237 if oldReport.resolverURLString != newReport.resolverURLString {
DomainDig/DomainCompareView.swift added +82
@@ -0,0 +1,82 @@
1import SwiftUI
2
3/// Side-by-side comparison of two tracked domains' latest reports, reusing the
4/// same section-diff rendering (`DomainDiffView`) that time-based history diffs
5/// use, via `DiffService.compare(domainA:domainB:)`.
6struct DomainCompareView: View {
7 @Bindable var viewModel: DomainViewModel
8 @State private var domainAID: UUID?
9 @State private var domainBID: UUID?
10
11 private var states: [PortfolioDomainStatus] {
12 viewModel.portfolioDashboardData.domainStates
13 }
14
15 private var stateA: PortfolioDomainStatus? {
16 states.first { $0.trackedDomain.id == domainAID }
17 }
18
19 private var stateB: PortfolioDomainStatus? {
20 states.first { $0.trackedDomain.id == domainBID }
21 }
22
23 private var result: DomainComparisonResult? {
24 guard let stateA, let stateB, stateA.trackedDomain.id != stateB.trackedDomain.id else {
25 return nil
26 }
27 return DiffService.compare(domainA: stateA.report, domainB: stateB.report)
28 }
29
30 var body: some View {
31 List {
32 Section("Domains") {
33 domainPicker("Domain A", selection: $domainAID)
34 domainPicker("Domain B", selection: $domainBID)
35 }
36
37 if domainAID != nil, domainAID == domainBID {
38 Section {
39 Text("Choose two different domains to compare.")
40 .font(.callout)
41 .foregroundStyle(.secondary)
42 }
43 }
44
45 if let result {
46 Section {
47 DomainDiffView(
48 title: "\(result.domainA) vs \(result.domainB)",
49 sections: result.sections,
50 contextNote: result.contextNote,
51 showsUnchanged: true,
52 highlightedSectionID: nil
53 )
54 }
55 .listRowBackground(Color.clear)
56 } else if domainAID == nil || domainBID == nil {
57 Section {
58 EmptyStateCardView(
59 title: "Pick Two Domains",
60 message: "Select a domain in each slot to see a side-by-side comparison across DNS, TLS, ownership, and risk.",
61 suggestion: "Both domains must already be tracked.",
62 systemImage: "arrow.left.arrow.right",
63 showsCardBackground: false
64 )
65 }
66 .listRowBackground(Color(.systemGray6).opacity(0.5))
67 }
68 }
69 .scrollContentBackground(.hidden)
70 .background(Color.black)
71 .navigationTitle("Compare Domains")
72 }
73
74 private func domainPicker(_ label: String, selection: Binding<UUID?>) -> some View {
75 Picker(label, selection: selection) {
76 Text("Select a domain").tag(UUID?.none)
77 ForEach(states) { state in
78 Text(state.trackedDomain.domain).tag(Optional(state.trackedDomain.id))
79 }
80 }
81 }
82}
DomainDig/WatchlistView.swift +6
@@ -120,6 +120,12 @@ struct WatchlistView: View {
120120 showWorkflowAddSheet = true
121121 }
122122
123 if viewModel.trackedDomains.count >= 2 {
124 NavigationLink("Compare Domains") {
125 DomainCompareView(viewModel: viewModel)
126 }
127 }
128
123129 Button("Export TXT") {
124130 shareTrackedDomains(format: .text)
125131 }