Commit 321a10f854

321a10f85435fdcbfb54f109ee53df895a550a42

parent: 9092ab60f8

Unsigned

cmc <hello@cleberg.net> · 2026-07-25 05:10 UTC

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.

Layout: unified · split

DomainDig/DomainViewModel+History.swift added +147
@@ -0,0 +1,147 @@
1import Foundation
2
3/// History query surface of `DomainViewModel`: reading snapshots for a domain,
4/// grouping them into a timeline, the two-snapshot selection model, and
5/// generating/navigating diffs between snapshots.
6///
7/// This is the read/compare side of history. The pipeline that *produces*
8/// history — `performLookup`, `applySnapshot`, `saveHistoryEntry`,
9/// `persistHistory`, and the snapshot-metadata bookkeeping — stays on the main
10/// type with the inspection code.
11extension DomainViewModel {
12 func resolverMismatchNote(for entry: HistoryEntry) -> String? {
13 guard entry.resolverURLString != resolverURLString else { return nil }
14 return "Current resolver differs from this snapshot. Re-running may produce different evidence."
15 }
16
17 func resolverMismatchNote(for trackedDomain: TrackedDomain) -> String? {
18 guard let snapshot = latestSnapshot(for: trackedDomain), snapshot.resolverURLString != resolverURLString else {
19 return nil
20 }
21 return "Current resolver differs from the latest snapshot for this tracked domain."
22 }
23
24 func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? {
25 previousHistoryEntry(for: entry)?.snapshot
26 }
27
28 func timelineEntries(for domain: String) -> [SnapshotSummary] {
29 historyEntries(for: domain).map(\.snapshotSummary)
30 }
31
32 func timelineSections(for domain: String, grouping: TimelineGroupingOption? = nil) -> [TimelineSection] {
33 let entries = timelineEntries(for: domain)
34 let grouping = grouping ?? timelineGrouping
35
36 guard grouping == .relativeDay else {
37 return entries.isEmpty ? [] : [TimelineSection(id: "all", title: "All Snapshots", entries: entries)]
38 }
39
40 let calendar = Calendar.current
41 let today = Date()
42 let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
43 let grouped = Dictionary(grouping: entries) { entry -> String in
44 if calendar.isDate(entry.timestamp, inSameDayAs: today) {
45 return "Today"
46 }
47 if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) {
48 return "Yesterday"
49 }
50 return "Older"
51 }
52
53 return ["Today", "Yesterday", "Older"].compactMap { title in
54 guard let items = grouped[title], !items.isEmpty else { return nil }
55 return TimelineSection(id: title.lowercased(), title: title, entries: items)
56 }
57 }
58
59 func historyEntries(for domain: String) -> [HistoryEntry] {
60 history
61 .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
62 .sorted { $0.timestamp > $1.timestamp }
63 }
64
65 func historyEntry(withID id: UUID) -> HistoryEntry? {
66 history.first(where: { $0.id == id })
67 }
68
69 func previousHistoryEntry(for entry: HistoryEntry) -> HistoryEntry? {
70 let siblings = historyEntries(for: entry.domain)
71 guard let index = siblings.firstIndex(where: { $0.id == entry.id }) else { return nil }
72 let nextIndex = index + 1
73 guard siblings.indices.contains(nextIndex) else { return nil }
74 return siblings[nextIndex]
75 }
76
77 func toggleSnapshotSelection(_ entry: HistoryEntry) {
78 if selectedSnapshotIDs.contains(entry.id) {
79 selectedSnapshotIDs.remove(entry.id)
80 } else if selectedSnapshotIDs.count < 2 {
81 selectedSnapshotIDs.insert(entry.id)
82 } else if let oldest = selectedSnapshotIDs.first {
83 selectedSnapshotIDs.remove(oldest)
84 selectedSnapshotIDs.insert(entry.id)
85 }
86 }
87
88 func clearSnapshotSelection() {
89 selectedSnapshotIDs.removeAll()
90 }
91
92 var selectedSnapshots: [HistoryEntry] {
93 selectedSnapshotIDs.compactMap(historyEntry(withID:)).sorted { $0.timestamp < $1.timestamp }
94 }
95
96 @discardableResult
97 func generateDiffForSelectedSnapshots(focusSectionID: String? = nil) -> DomainDiff? {
98 guard selectedSnapshots.count == 2 else {
99 activeDomainDiff = nil
100 activeDiffChangeIndex = 0
101 return nil
102 }
103
104 let diff = DiffService.compare(from: selectedSnapshots[0].snapshot, to: selectedSnapshots[1].snapshot)
105 activeDomainDiff = diff
106 if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
107 activeDiffChangeIndex = index
108 } else {
109 activeDiffChangeIndex = 0
110 }
111 return diff
112 }
113
114 func generateDiff(from olderEntry: HistoryEntry, to newerEntry: HistoryEntry, focusSectionID: String? = nil) -> DomainDiff {
115 selectedSnapshotIDs = [olderEntry.id, newerEntry.id]
116 let diff = DiffService.compare(from: olderEntry.snapshot, to: newerEntry.snapshot)
117 activeDomainDiff = diff
118 if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
119 activeDiffChangeIndex = index
120 } else {
121 activeDiffChangeIndex = 0
122 }
123 return diff
124 }
125
126 func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? {
127 guard let historyEntryID = batchResult.historyEntryID else { return nil }
128 return history.first(where: { $0.id == historyEntryID })
129 }
130
131 var currentDiffTargetSectionID: String? {
132 guard let activeDomainDiff, activeDomainDiff.changedSectionIDs.indices.contains(activeDiffChangeIndex) else {
133 return nil
134 }
135 return activeDomainDiff.changedSectionIDs[activeDiffChangeIndex]
136 }
137
138 func moveToNextDiffChange() {
139 guard let activeDomainDiff, !activeDomainDiff.changedSectionIDs.isEmpty else { return }
140 activeDiffChangeIndex = min(activeDiffChangeIndex + 1, activeDomainDiff.changedSectionIDs.count - 1)
141 }
142
143 func moveToPreviousDiffChange() {
144 guard activeDomainDiff != nil else { return }
145 activeDiffChangeIndex = max(activeDiffChangeIndex - 1, 0)
146 }
147}
DomainDig/DomainViewModel.swift −136
@@ -3047,142 +3047,6 @@ final class DomainViewModel {
30473047 }
30483048 }
30493049
3050 func resolverMismatchNote(for entry: HistoryEntry) -> String? {
3051 guard entry.resolverURLString != resolverURLString else { return nil }
3052 return "Current resolver differs from this snapshot. Re-running may produce different evidence."
3053 }
3054
3055 func resolverMismatchNote(for trackedDomain: TrackedDomain) -> String? {
3056 guard let snapshot = latestSnapshot(for: trackedDomain), snapshot.resolverURLString != resolverURLString else {
3057 return nil
3058 }
3059 return "Current resolver differs from the latest snapshot for this tracked domain."
3060 }
3061
3062 func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? {
3063 previousHistoryEntry(for: entry)?.snapshot
3064 }
3065
3066 func timelineEntries(for domain: String) -> [SnapshotSummary] {
3067 historyEntries(for: domain).map(\.snapshotSummary)
3068 }
3069
3070 func timelineSections(for domain: String, grouping: TimelineGroupingOption? = nil) -> [TimelineSection] {
3071 let entries = timelineEntries(for: domain)
3072 let grouping = grouping ?? timelineGrouping
3073
3074 guard grouping == .relativeDay else {
3075 return entries.isEmpty ? [] : [TimelineSection(id: "all", title: "All Snapshots", entries: entries)]
3076 }
3077
3078 let calendar = Calendar.current
3079 let today = Date()
3080 let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
3081 let grouped = Dictionary(grouping: entries) { entry -> String in
3082 if calendar.isDate(entry.timestamp, inSameDayAs: today) {
3083 return "Today"
3084 }
3085 if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) {
3086 return "Yesterday"
3087 }
3088 return "Older"
3089 }
3090
3091 return ["Today", "Yesterday", "Older"].compactMap { title in
3092 guard let items = grouped[title], !items.isEmpty else { return nil }
3093 return TimelineSection(id: title.lowercased(), title: title, entries: items)
3094 }
3095 }
3096
3097 func historyEntries(for domain: String) -> [HistoryEntry] {
3098 history
3099 .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
3100 .sorted { $0.timestamp > $1.timestamp }
3101 }
3102
3103 func historyEntry(withID id: UUID) -> HistoryEntry? {
3104 history.first(where: { $0.id == id })
3105 }
3106
3107 func previousHistoryEntry(for entry: HistoryEntry) -> HistoryEntry? {
3108 let siblings = historyEntries(for: entry.domain)
3109 guard let index = siblings.firstIndex(where: { $0.id == entry.id }) else { return nil }
3110 let nextIndex = index + 1
3111 guard siblings.indices.contains(nextIndex) else { return nil }
3112 return siblings[nextIndex]
3113 }
3114
3115 func toggleSnapshotSelection(_ entry: HistoryEntry) {
3116 if selectedSnapshotIDs.contains(entry.id) {
3117 selectedSnapshotIDs.remove(entry.id)
3118 } else if selectedSnapshotIDs.count < 2 {
3119 selectedSnapshotIDs.insert(entry.id)
3120 } else if let oldest = selectedSnapshotIDs.first {
3121 selectedSnapshotIDs.remove(oldest)
3122 selectedSnapshotIDs.insert(entry.id)
3123 }
3124 }
3125
3126 func clearSnapshotSelection() {
3127 selectedSnapshotIDs.removeAll()
3128 }
3129
3130 var selectedSnapshots: [HistoryEntry] {
3131 selectedSnapshotIDs.compactMap(historyEntry(withID:)).sorted { $0.timestamp < $1.timestamp }
3132 }
3133
3134 @discardableResult
3135 func generateDiffForSelectedSnapshots(focusSectionID: String? = nil) -> DomainDiff? {
3136 guard selectedSnapshots.count == 2 else {
3137 activeDomainDiff = nil
3138 activeDiffChangeIndex = 0
3139 return nil
3140 }
3141
3142 let diff = DiffService.compare(from: selectedSnapshots[0].snapshot, to: selectedSnapshots[1].snapshot)
3143 activeDomainDiff = diff
3144 if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
3145 activeDiffChangeIndex = index
3146 } else {
3147 activeDiffChangeIndex = 0
3148 }
3149 return diff
3150 }
3151
3152 func generateDiff(from olderEntry: HistoryEntry, to newerEntry: HistoryEntry, focusSectionID: String? = nil) -> DomainDiff {
3153 selectedSnapshotIDs = [olderEntry.id, newerEntry.id]
3154 let diff = DiffService.compare(from: olderEntry.snapshot, to: newerEntry.snapshot)
3155 activeDomainDiff = diff
3156 if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
3157 activeDiffChangeIndex = index
3158 } else {
3159 activeDiffChangeIndex = 0
3160 }
3161 return diff
3162 }
3163
3164 func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? {
3165 guard let historyEntryID = batchResult.historyEntryID else { return nil }
3166 return history.first(where: { $0.id == historyEntryID })
3167 }
3168
3169 var currentDiffTargetSectionID: String? {
3170 guard let activeDomainDiff, activeDomainDiff.changedSectionIDs.indices.contains(activeDiffChangeIndex) else {
3171 return nil
3172 }
3173 return activeDomainDiff.changedSectionIDs[activeDiffChangeIndex]
3174 }
3175
3176 func moveToNextDiffChange() {
3177 guard let activeDomainDiff, !activeDomainDiff.changedSectionIDs.isEmpty else { return }
3178 activeDiffChangeIndex = min(activeDiffChangeIndex + 1, activeDomainDiff.changedSectionIDs.count - 1)
3179 }
3180
3181 func moveToPreviousDiffChange() {
3182 guard activeDomainDiff != nil else { return }
3183 activeDiffChangeIndex = max(activeDiffChangeIndex - 1, 0)
3184 }
3185
31863050 private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] {
31873051 let latestEntries = latestSnapshots(for: domains)
31883052