krz/domain-dig

an ios app for DNS & SSL analysis

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

fbf2d9bf2cf50eb681afe18deac06a96c04004e6

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-07-17T15:45:42Z

v4.7.0: Add watchlist tags and saved filter views

- TrackedDomain.tags: [String] (backward-compatible custom decode), with
  updateTags(_:for:) and a normalized/deduplicated write path.
- Tag editing in TrackedDomainDetailView (comma-separated field, chip display).
- Tag filter chips in WatchlistView (TagFilterChipRowView), integrated into
  filteredTrackedDomains alongside the existing filter/search.
- Saved views: name + snapshot the current tag/filter/sort as a
  WatchlistSavedView preset (UserDefaults-backed, not part of backup/restore),
  with a management sheet to apply or delete presets.
 DomainDig/DomainDigUI.swift     |  54 ++++++++++++++++++++
 DomainDig/DomainViewModel.swift |  69 ++++++++++++++++++++++++++
 DomainDig/Models.swift          |  34 +++++++++++--
 DomainDig/WatchlistView.swift   | 107 ++++++++++++++++++++++++++++++++++++++++
 4 files changed, 261 insertions(+), 3 deletions(-)

diff --git a/DomainDig/DomainDigUI.swift b/DomainDig/DomainDigUI.swift
index 592d7a7..272c678 100644
--- a/DomainDig/DomainDigUI.swift
+++ b/DomainDig/DomainDigUI.swift
@@ -332,3 +332,57 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
         }
     }
 }
+
+/// A horizontally scrolling row of read-only tag chips, e.g. for a tracked
+/// domain's detail view.
+struct TagChipRowView: View {
+    let tags: [String]
+
+    var body: some View {
+        ScrollView(.horizontal, showsIndicators: false) {
+            HStack(spacing: 8) {
+                ForEach(tags, id: \.self) { tag in
+                    Text(tag)
+                        .font(.caption)
+                        .padding(.horizontal, 10)
+                        .padding(.vertical, 5)
+                        .background(Color(.systemGray5).opacity(0.6), in: Capsule())
+                }
+            }
+        }
+    }
+}
+
+/// A horizontally scrolling row of selectable tag chips used to filter a list,
+/// with an "All" chip to clear the selection.
+struct TagFilterChipRowView: View {
+    let tags: [String]
+    @Binding var selection: String?
+
+    var body: some View {
+        ScrollView(.horizontal, showsIndicators: false) {
+            HStack(spacing: 8) {
+                filterChip(title: "All", isSelected: selection == nil) {
+                    selection = nil
+                }
+                ForEach(tags, id: \.self) { tag in
+                    filterChip(title: tag, isSelected: selection == tag) {
+                        selection = (selection == tag) ? nil : tag
+                    }
+                }
+            }
+        }
+    }
+
+    private func filterChip(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
+        Button(action: action) {
+            Text(title)
+                .font(.caption)
+                .padding(.horizontal, 10)
+                .padding(.vertical, 5)
+                .background(isSelected ? Color.cyan.opacity(0.3) : Color(.systemGray5).opacity(0.6), in: Capsule())
+                .foregroundStyle(isSelected ? Color.cyan : Color.primary)
+        }
+        .buttonStyle(.plain)
+    }
+}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 3c92ff6..33756df 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -312,6 +312,8 @@ final class DomainViewModel {
     var watchlistSearchText = ""
     var watchlistFilter: WatchlistFilterOption = .all
     var watchlistSortOption: WatchlistSortOption = .pinned
+    var watchlistTagFilter: String?
+    var watchlistSavedViews: [WatchlistSavedView] = DomainViewModel.loadWatchlistSavedViews()
     var dashboardSearchText = ""
     var dashboardFilter: PortfolioFilterOption = .all
     var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings()
@@ -432,6 +434,10 @@ final class DomainViewModel {
                 return false
             }
 
+            if let watchlistTagFilter, !trackedDomain.tags.contains(watchlistTagFilter) {
+                return false
+            }
+
             switch watchlistFilter {
             case .all:
                 return true
@@ -888,6 +894,69 @@ final class DomainViewModel {
         persistTrackedDomains()
     }
 
+    func updateTags(_ tags: [String], for trackedDomain: TrackedDomain) {
+        guard canEdit(trackedDomain) else { return }
+        guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
+        let normalized = Self.normalizedTags(tags)
+        trackedDomains[index].tags = normalized
+        trackedDomains[index].updatedAt = Date()
+        persistTrackedDomains()
+    }
+
+    /// All tags currently in use across the watchlist, sorted for stable display.
+    var allWatchlistTags: [String] {
+        Array(Set(trackedDomains.flatMap(\.tags))).sorted()
+    }
+
+    func saveCurrentWatchlistView(name: String) {
+        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedName.isEmpty else { return }
+        let view = WatchlistSavedView(
+            name: trimmedName,
+            tag: watchlistTagFilter,
+            filter: watchlistFilter,
+            sort: watchlistSortOption
+        )
+        watchlistSavedViews.append(view)
+        persistWatchlistSavedViews()
+    }
+
+    func applyWatchlistSavedView(_ view: WatchlistSavedView) {
+        watchlistTagFilter = view.tag
+        watchlistFilter = view.filter
+        watchlistSortOption = view.sort
+    }
+
+    func deleteWatchlistSavedViews(at offsets: IndexSet) {
+        watchlistSavedViews.remove(atOffsets: offsets)
+        persistWatchlistSavedViews()
+    }
+
+    private static let watchlistSavedViewsKey = "watchlistSavedViews"
+
+    private static func loadWatchlistSavedViews() -> [WatchlistSavedView] {
+        guard let data = UserDefaults.standard.data(forKey: watchlistSavedViewsKey),
+              let views = try? JSONDecoder().decode([WatchlistSavedView].self, from: data)
+        else { return [] }
+        return views
+    }
+
+    private func persistWatchlistSavedViews() {
+        guard let data = try? JSONEncoder().encode(watchlistSavedViews) else { return }
+        UserDefaults.standard.set(data, forKey: Self.watchlistSavedViewsKey)
+    }
+
+    private static func normalizedTags(_ tags: [String]) -> [String] {
+        var seen = Set<String>()
+        var normalized: [String] = []
+        for tag in tags {
+            let trimmed = tag.trimmingCharacters(in: .whitespacesAndNewlines)
+            guard !trimmed.isEmpty, seen.insert(trimmed.lowercased()).inserted else { continue }
+            normalized.append(trimmed)
+        }
+        return normalized.sorted()
+    }
+
     func removeHistoryEntries(at offsets: IndexSet) {
         history.remove(atOffsets: offsets)
         persistHistory()
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index d14ff4a..b86fc07 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -882,7 +882,7 @@ struct TimelineSection: Identifiable, Equatable {
     let entries: [SnapshotSummary]
 }
 
-enum WatchlistFilterOption: String, CaseIterable, Identifiable {
+enum WatchlistFilterOption: String, CaseIterable, Identifiable, Codable {
     case all
     case pinnedOnly
     case changedOnly
@@ -901,7 +901,7 @@ enum WatchlistFilterOption: String, CaseIterable, Identifiable {
     }
 }
 
-enum WatchlistSortOption: String, CaseIterable, Identifiable {
+enum WatchlistSortOption: String, CaseIterable, Identifiable, Codable {
     case pinned
     case recentlyUpdated
     case alphabetical
@@ -920,6 +920,30 @@ enum WatchlistSortOption: String, CaseIterable, Identifiable {
     }
 }
 
+/// A named watchlist filter preset (tag, filter, sort) the user can save and
+/// reapply. Stored locally in UserDefaults; not included in backup/restore.
+struct WatchlistSavedView: Codable, Identifiable, Equatable {
+    let id: UUID
+    var name: String
+    var tag: String?
+    var filter: WatchlistFilterOption
+    var sort: WatchlistSortOption
+
+    init(
+        id: UUID = UUID(),
+        name: String,
+        tag: String?,
+        filter: WatchlistFilterOption,
+        sort: WatchlistSortOption
+    ) {
+        self.id = id
+        self.name = name
+        self.tag = tag
+        self.filter = filter
+        self.sort = sort
+    }
+}
+
 enum PortfolioFilterOption: String, CaseIterable, Identifiable {
     case all
     case healthy
@@ -1258,6 +1282,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
     var monitoringState: MonitoringState
     var pendingMonitoringAlerts: [MonitoringPendingAlert]
     var collaboration: CollaborationMetadata?
+    var tags: [String]
 
     init(
         id: UUID = UUID(),
@@ -1277,7 +1302,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
         lastAlertAt: Date? = nil,
         monitoringState: MonitoringState = MonitoringState(),
         pendingMonitoringAlerts: [MonitoringPendingAlert] = [],
-        collaboration: CollaborationMetadata? = nil
+        collaboration: CollaborationMetadata? = nil,
+        tags: [String] = []
     ) {
         self.id = id
         self.domain = domain
@@ -1297,6 +1323,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
         self.monitoringState = monitoringState
         self.pendingMonitoringAlerts = pendingMonitoringAlerts
         self.collaboration = collaboration
+        self.tags = tags
     }
 
     init(from decoder: Decoder) throws {
@@ -1319,6 +1346,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
         monitoringState = try container.decodeIfPresent(MonitoringState.self, forKey: .monitoringState) ?? MonitoringState()
         pendingMonitoringAlerts = try container.decodeIfPresent([MonitoringPendingAlert].self, forKey: .pendingMonitoringAlerts) ?? []
         collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration)
+        tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
     }
 }
 
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index 43dc66f..e017502 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -10,6 +10,9 @@ struct WatchlistView: View {
     @State private var newTrackedDomain = ""
     @State private var addDomainError: String?
     @FocusState private var isAddDomainFieldFocused: Bool
+    @State private var showSavedViewsSheet = false
+    @State private var showSaveViewPrompt = false
+    @State private var newSavedViewName = ""
 
     private var pinnedDomains: [TrackedDomain] {
         viewModel.filteredTrackedDomains.filter(\.isPinned)
@@ -23,6 +26,14 @@ struct WatchlistView: View {
         let _ = purchaseService.currentTier
 
         List {
+            if !viewModel.allWatchlistTags.isEmpty {
+                Section {
+                    TagFilterChipRowView(tags: viewModel.allWatchlistTags, selection: $viewModel.watchlistTagFilter)
+                }
+                .listRowBackground(Color.clear)
+                .listRowInsets(EdgeInsets())
+            }
+
             if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
                 Section("Refresh Progress") {
                     VStack(alignment: .leading, spacing: 8) {
@@ -110,6 +121,17 @@ struct WatchlistView: View {
                             }
                         }
 
+                        Button("Save Current View…") {
+                            newSavedViewName = ""
+                            showSaveViewPrompt = true
+                        }
+
+                        if !viewModel.watchlistSavedViews.isEmpty {
+                            Button("Saved Views") {
+                                showSavedViewsSheet = true
+                            }
+                        }
+
                         Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") {
                             AppHaptics.refresh()
                             viewModel.refreshAllTrackedDomains()
@@ -211,6 +233,49 @@ struct WatchlistView: View {
                 availableDomains: viewModel.filteredTrackedDomains.map(\.domain)
             )
         }
+        .alert("Save Current View", isPresented: $showSaveViewPrompt) {
+            TextField("View name", text: $newSavedViewName)
+            Button("Save") {
+                viewModel.saveCurrentWatchlistView(name: newSavedViewName)
+            }
+            Button("Cancel", role: .cancel) {}
+        } message: {
+            Text("Saves the current tag, filter, and sort as a reusable preset.")
+        }
+        .sheet(isPresented: $showSavedViewsSheet) {
+            NavigationStack {
+                List {
+                    ForEach(viewModel.watchlistSavedViews) { view in
+                        Button {
+                            viewModel.applyWatchlistSavedView(view)
+                            showSavedViewsSheet = false
+                        } label: {
+                            VStack(alignment: .leading, spacing: 2) {
+                                Text(view.name)
+                                    .foregroundStyle(.primary)
+                                Text([view.tag, view.filter.title, view.sort.title].compactMap { $0 }.joined(separator: " • "))
+                                    .font(.caption)
+                                    .foregroundStyle(.secondary)
+                            }
+                        }
+                    }
+                    .onDelete { offsets in
+                        viewModel.deleteWatchlistSavedViews(at: offsets)
+                    }
+                }
+                .navigationTitle("Saved Views")
+                .toolbar {
+                    ToolbarItem(placement: .cancellationAction) {
+                        Button("Done") {
+                            showSavedViewsSheet = false
+                        }
+                    }
+                    ToolbarItem(placement: .topBarTrailing) {
+                        EditButton()
+                    }
+                }
+            }
+        }
         .preferredColorScheme(.dark)
     }
 
@@ -480,6 +545,8 @@ struct TrackedDomainDetailView: View {
 
     @State private var noteDraft = ""
     @State private var isEditingNote = false
+    @State private var tagsDraft = ""
+    @State private var isEditingTags = false
     @State private var showRerunOptions = false
     @State private var shareEntity: ShareableEntity?
     @State private var showingAuditTimeline = false
@@ -566,6 +633,14 @@ struct TrackedDomainDetailView: View {
                 }
                 .disabled(!viewModel.canEdit(liveTrackedDomain))
 
+                Button {
+                    tagsDraft = liveTrackedDomain.tags.joined(separator: ", ")
+                    isEditingTags = true
+                } label: {
+                    Label(liveTrackedDomain.tags.isEmpty ? "Add Tags" : "Edit Tags", systemImage: "tag")
+                }
+                .disabled(!viewModel.canEdit(liveTrackedDomain))
+
                 Button {
                     shareEntity = .trackedDomain(liveTrackedDomain.domain)
                 } label: {
@@ -574,6 +649,13 @@ struct TrackedDomainDetailView: View {
             }
             .listRowBackground(Color(.systemGray6).opacity(0.5))
 
+            if !liveTrackedDomain.tags.isEmpty {
+                Section("Tags") {
+                    TagChipRowView(tags: liveTrackedDomain.tags)
+                }
+                .listRowBackground(Color(.systemGray6).opacity(0.5))
+            }
+
             Section("Monitoring Status") {
                 LabeledContent("State", value: viewModel.monitoringStatusLabel(for: liveTrackedDomain))
                 LabeledContent("Current Interval", value: viewModel.monitoringIntervalLabel(for: liveTrackedDomain))
@@ -677,6 +759,31 @@ struct TrackedDomainDetailView: View {
                 }
             }
         }
+        .sheet(isPresented: $isEditingTags) {
+            NavigationStack {
+                Form {
+                    Section("Tags") {
+                        TextField("comma, separated, tags", text: $tagsDraft)
+                            .textInputAutocapitalization(.never)
+                    }
+                }
+                .navigationTitle("Edit Tags")
+                .toolbar {
+                    ToolbarItem(placement: .cancellationAction) {
+                        Button("Cancel") {
+                            isEditingTags = false
+                        }
+                    }
+                    ToolbarItem(placement: .confirmationAction) {
+                        Button("Save") {
+                            let tags = tagsDraft.components(separatedBy: ",")
+                            viewModel.updateTags(tags, for: liveTrackedDomain)
+                            isEditingTags = false
+                        }
+                    }
+                }
+            }
+        }
         .sheet(item: $shareEntity) { entity in
             CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain)
         }