krz/domain-dig

an ios app for DNS & SSL analysis

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

321a10f85435fdcbfb54f109ee53df895a550a42

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-07-25T05:10:46Z

refactor: extract history query surface into DomainViewModel+History (v5 step 4, 5/n)

Fifth slice of the DomainViewModel decomposition.

- Moves the history query/compare surface into DomainViewModel+History.swift:
  per-domain snapshot reads (historyEntries/historyEntry/previousHistoryEntry),
  timeline grouping (timelineEntries/timelineSections), the two-snapshot
  selection model (toggle/clear/selectedSnapshots), diff generation and
  navigation (generateDiff*/moveTo*DiffChange/currentDiffTargetSectionID), and
  the resolver-mismatch notes. Pure move.
- The inspection *pipeline* that produces history — performLookup,
  applySnapshot, saveHistoryEntry, persistHistory, snapshot-metadata bookkeeping
  — stays on the main type with the inspection code.

This slice needs zero visibility changes: every dependency (the history array,
diff state, latestSnapshot) was already internal. The main-file diff is pure
deletion.

DomainViewModel.swift: 4306 -> 4171 lines (4864 at the start of step 4; -693
total). App builds clean; unit suite 58/58. No project.pbxproj change.
 DomainDig/DomainViewModel+History.swift | 147 ++++++++++++++++++++++++++++++++
 DomainDig/DomainViewModel.swift         | 136 -----------------------------
 2 files changed, 147 insertions(+), 136 deletions(-)

diff --git a/DomainDig/DomainViewModel+History.swift b/DomainDig/DomainViewModel+History.swift
new file mode 100644
index 0000000..7b6f80e
--- /dev/null
+++ b/DomainDig/DomainViewModel+History.swift
@@ -0,0 +1,147 @@
+import Foundation
+
+/// History query surface of `DomainViewModel`: reading snapshots for a domain,
+/// grouping them into a timeline, the two-snapshot selection model, and
+/// generating/navigating diffs between snapshots.
+///
+/// This is the read/compare side of history. The pipeline that *produces*
+/// history — `performLookup`, `applySnapshot`, `saveHistoryEntry`,
+/// `persistHistory`, and the snapshot-metadata bookkeeping — stays on the main
+/// type with the inspection code.
+extension DomainViewModel {
+    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."
+    }
+
+    func resolverMismatchNote(for trackedDomain: TrackedDomain) -> String? {
+        guard let snapshot = latestSnapshot(for: trackedDomain), snapshot.resolverURLString != resolverURLString else {
+            return nil
+        }
+        return "Current resolver differs from the latest snapshot for this tracked domain."
+    }
+
+    func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? {
+        previousHistoryEntry(for: entry)?.snapshot
+    }
+
+    func timelineEntries(for domain: String) -> [SnapshotSummary] {
+        historyEntries(for: domain).map(\.snapshotSummary)
+    }
+
+    func timelineSections(for domain: String, grouping: TimelineGroupingOption? = nil) -> [TimelineSection] {
+        let entries = timelineEntries(for: domain)
+        let grouping = grouping ?? timelineGrouping
+
+        guard grouping == .relativeDay else {
+            return entries.isEmpty ? [] : [TimelineSection(id: "all", title: "All Snapshots", entries: entries)]
+        }
+
+        let calendar = Calendar.current
+        let today = Date()
+        let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
+        let grouped = Dictionary(grouping: entries) { entry -> String in
+            if calendar.isDate(entry.timestamp, inSameDayAs: today) {
+                return "Today"
+            }
+            if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) {
+                return "Yesterday"
+            }
+            return "Older"
+        }
+
+        return ["Today", "Yesterday", "Older"].compactMap { title in
+            guard let items = grouped[title], !items.isEmpty else { return nil }
+            return TimelineSection(id: title.lowercased(), title: title, entries: items)
+        }
+    }
+
+    func historyEntries(for domain: String) -> [HistoryEntry] {
+        history
+            .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
+            .sorted { $0.timestamp > $1.timestamp }
+    }
+
+    func historyEntry(withID id: UUID) -> HistoryEntry? {
+        history.first(where: { $0.id == id })
+    }
+
+    func previousHistoryEntry(for entry: HistoryEntry) -> HistoryEntry? {
+        let siblings = historyEntries(for: entry.domain)
+        guard let index = siblings.firstIndex(where: { $0.id == entry.id }) else { return nil }
+        let nextIndex = index + 1
+        guard siblings.indices.contains(nextIndex) else { return nil }
+        return siblings[nextIndex]
+    }
+
+    func toggleSnapshotSelection(_ entry: HistoryEntry) {
+        if selectedSnapshotIDs.contains(entry.id) {
+            selectedSnapshotIDs.remove(entry.id)
+        } else if selectedSnapshotIDs.count < 2 {
+            selectedSnapshotIDs.insert(entry.id)
+        } else if let oldest = selectedSnapshotIDs.first {
+            selectedSnapshotIDs.remove(oldest)
+            selectedSnapshotIDs.insert(entry.id)
+        }
+    }
+
+    func clearSnapshotSelection() {
+        selectedSnapshotIDs.removeAll()
+    }
+
+    var selectedSnapshots: [HistoryEntry] {
+        selectedSnapshotIDs.compactMap(historyEntry(withID:)).sorted { $0.timestamp < $1.timestamp }
+    }
+
+    @discardableResult
+    func generateDiffForSelectedSnapshots(focusSectionID: String? = nil) -> DomainDiff? {
+        guard selectedSnapshots.count == 2 else {
+            activeDomainDiff = nil
+            activeDiffChangeIndex = 0
+            return nil
+        }
+
+        let diff = DiffService.compare(from: selectedSnapshots[0].snapshot, to: selectedSnapshots[1].snapshot)
+        activeDomainDiff = diff
+        if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
+            activeDiffChangeIndex = index
+        } else {
+            activeDiffChangeIndex = 0
+        }
+        return diff
+    }
+
+    func generateDiff(from olderEntry: HistoryEntry, to newerEntry: HistoryEntry, focusSectionID: String? = nil) -> DomainDiff {
+        selectedSnapshotIDs = [olderEntry.id, newerEntry.id]
+        let diff = DiffService.compare(from: olderEntry.snapshot, to: newerEntry.snapshot)
+        activeDomainDiff = diff
+        if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
+            activeDiffChangeIndex = index
+        } else {
+            activeDiffChangeIndex = 0
+        }
+        return diff
+    }
+
+    func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? {
+        guard let historyEntryID = batchResult.historyEntryID else { return nil }
+        return history.first(where: { $0.id == historyEntryID })
+    }
+
+    var currentDiffTargetSectionID: String? {
+        guard let activeDomainDiff, activeDomainDiff.changedSectionIDs.indices.contains(activeDiffChangeIndex) else {
+            return nil
+        }
+        return activeDomainDiff.changedSectionIDs[activeDiffChangeIndex]
+    }
+
+    func moveToNextDiffChange() {
+        guard let activeDomainDiff, !activeDomainDiff.changedSectionIDs.isEmpty else { return }
+        activeDiffChangeIndex = min(activeDiffChangeIndex + 1, activeDomainDiff.changedSectionIDs.count - 1)
+    }
+
+    func moveToPreviousDiffChange() {
+        guard activeDomainDiff != nil else { return }
+        activeDiffChangeIndex = max(activeDiffChangeIndex - 1, 0)
+    }
+}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index e0f649b..7246112 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -3047,142 +3047,6 @@ final class DomainViewModel {
         }
     }
 
-    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."
-    }
-
-    func resolverMismatchNote(for trackedDomain: TrackedDomain) -> String? {
-        guard let snapshot = latestSnapshot(for: trackedDomain), snapshot.resolverURLString != resolverURLString else {
-            return nil
-        }
-        return "Current resolver differs from the latest snapshot for this tracked domain."
-    }
-
-    func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? {
-        previousHistoryEntry(for: entry)?.snapshot
-    }
-
-    func timelineEntries(for domain: String) -> [SnapshotSummary] {
-        historyEntries(for: domain).map(\.snapshotSummary)
-    }
-
-    func timelineSections(for domain: String, grouping: TimelineGroupingOption? = nil) -> [TimelineSection] {
-        let entries = timelineEntries(for: domain)
-        let grouping = grouping ?? timelineGrouping
-
-        guard grouping == .relativeDay else {
-            return entries.isEmpty ? [] : [TimelineSection(id: "all", title: "All Snapshots", entries: entries)]
-        }
-
-        let calendar = Calendar.current
-        let today = Date()
-        let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
-        let grouped = Dictionary(grouping: entries) { entry -> String in
-            if calendar.isDate(entry.timestamp, inSameDayAs: today) {
-                return "Today"
-            }
-            if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) {
-                return "Yesterday"
-            }
-            return "Older"
-        }
-
-        return ["Today", "Yesterday", "Older"].compactMap { title in
-            guard let items = grouped[title], !items.isEmpty else { return nil }
-            return TimelineSection(id: title.lowercased(), title: title, entries: items)
-        }
-    }
-
-    func historyEntries(for domain: String) -> [HistoryEntry] {
-        history
-            .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
-            .sorted { $0.timestamp > $1.timestamp }
-    }
-
-    func historyEntry(withID id: UUID) -> HistoryEntry? {
-        history.first(where: { $0.id == id })
-    }
-
-    func previousHistoryEntry(for entry: HistoryEntry) -> HistoryEntry? {
-        let siblings = historyEntries(for: entry.domain)
-        guard let index = siblings.firstIndex(where: { $0.id == entry.id }) else { return nil }
-        let nextIndex = index + 1
-        guard siblings.indices.contains(nextIndex) else { return nil }
-        return siblings[nextIndex]
-    }
-
-    func toggleSnapshotSelection(_ entry: HistoryEntry) {
-        if selectedSnapshotIDs.contains(entry.id) {
-            selectedSnapshotIDs.remove(entry.id)
-        } else if selectedSnapshotIDs.count < 2 {
-            selectedSnapshotIDs.insert(entry.id)
-        } else if let oldest = selectedSnapshotIDs.first {
-            selectedSnapshotIDs.remove(oldest)
-            selectedSnapshotIDs.insert(entry.id)
-        }
-    }
-
-    func clearSnapshotSelection() {
-        selectedSnapshotIDs.removeAll()
-    }
-
-    var selectedSnapshots: [HistoryEntry] {
-        selectedSnapshotIDs.compactMap(historyEntry(withID:)).sorted { $0.timestamp < $1.timestamp }
-    }
-
-    @discardableResult
-    func generateDiffForSelectedSnapshots(focusSectionID: String? = nil) -> DomainDiff? {
-        guard selectedSnapshots.count == 2 else {
-            activeDomainDiff = nil
-            activeDiffChangeIndex = 0
-            return nil
-        }
-
-        let diff = DiffService.compare(from: selectedSnapshots[0].snapshot, to: selectedSnapshots[1].snapshot)
-        activeDomainDiff = diff
-        if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
-            activeDiffChangeIndex = index
-        } else {
-            activeDiffChangeIndex = 0
-        }
-        return diff
-    }
-
-    func generateDiff(from olderEntry: HistoryEntry, to newerEntry: HistoryEntry, focusSectionID: String? = nil) -> DomainDiff {
-        selectedSnapshotIDs = [olderEntry.id, newerEntry.id]
-        let diff = DiffService.compare(from: olderEntry.snapshot, to: newerEntry.snapshot)
-        activeDomainDiff = diff
-        if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
-            activeDiffChangeIndex = index
-        } else {
-            activeDiffChangeIndex = 0
-        }
-        return diff
-    }
-
-    func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? {
-        guard let historyEntryID = batchResult.historyEntryID else { return nil }
-        return history.first(where: { $0.id == historyEntryID })
-    }
-
-    var currentDiffTargetSectionID: String? {
-        guard let activeDomainDiff, activeDomainDiff.changedSectionIDs.indices.contains(activeDiffChangeIndex) else {
-            return nil
-        }
-        return activeDomainDiff.changedSectionIDs[activeDiffChangeIndex]
-    }
-
-    func moveToNextDiffChange() {
-        guard let activeDomainDiff, !activeDomainDiff.changedSectionIDs.isEmpty else { return }
-        activeDiffChangeIndex = min(activeDiffChangeIndex + 1, activeDomainDiff.changedSectionIDs.count - 1)
-    }
-
-    func moveToPreviousDiffChange() {
-        guard activeDomainDiff != nil else { return }
-        activeDiffChangeIndex = max(activeDiffChangeIndex - 1, 0)
-    }
-
     private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] {
         let latestEntries = latestSnapshots(for: domains)