Commit fbf2d9bf2c

fbf2d9bf2cf50eb681afe18deac06a96c04004e6

parent: 6e273c2676

Unsigned

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

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.

Layout: unified · split

DomainDig/DomainDigUI.swift +54
@@ -332,3 +332,57 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
332 } 332 }
333 } 333 }
334} 334}
335
336/// A horizontally scrolling row of read-only tag chips, e.g. for a tracked
337/// domain's detail view.
338struct TagChipRowView: View {
339 let tags: [String]
340
341 var body: some View {
342 ScrollView(.horizontal, showsIndicators: false) {
343 HStack(spacing: 8) {
344 ForEach(tags, id: \.self) { tag in
345 Text(tag)
346 .font(.caption)
347 .padding(.horizontal, 10)
348 .padding(.vertical, 5)
349 .background(Color(.systemGray5).opacity(0.6), in: Capsule())
350 }
351 }
352 }
353 }
354}
355
356/// A horizontally scrolling row of selectable tag chips used to filter a list,
357/// with an "All" chip to clear the selection.
358struct TagFilterChipRowView: View {
359 let tags: [String]
360 @Binding var selection: String?
361
362 var body: some View {
363 ScrollView(.horizontal, showsIndicators: false) {
364 HStack(spacing: 8) {
365 filterChip(title: "All", isSelected: selection == nil) {
366 selection = nil
367 }
368 ForEach(tags, id: \.self) { tag in
369 filterChip(title: tag, isSelected: selection == tag) {
370 selection = (selection == tag) ? nil : tag
371 }
372 }
373 }
374 }
375 }
376
377 private func filterChip(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
378 Button(action: action) {
379 Text(title)
380 .font(.caption)
381 .padding(.horizontal, 10)
382 .padding(.vertical, 5)
383 .background(isSelected ? Color.cyan.opacity(0.3) : Color(.systemGray5).opacity(0.6), in: Capsule())
384 .foregroundStyle(isSelected ? Color.cyan : Color.primary)
385 }
386 .buttonStyle(.plain)
387 }
388}
DomainDig/DomainViewModel.swift +69
@@ -312,6 +312,8 @@ final class DomainViewModel {
312 var watchlistSearchText = "" 312 var watchlistSearchText = ""
313 var watchlistFilter: WatchlistFilterOption = .all 313 var watchlistFilter: WatchlistFilterOption = .all
314 var watchlistSortOption: WatchlistSortOption = .pinned 314 var watchlistSortOption: WatchlistSortOption = .pinned
315 var watchlistTagFilter: String?
316 var watchlistSavedViews: [WatchlistSavedView] = DomainViewModel.loadWatchlistSavedViews()
315 var dashboardSearchText = "" 317 var dashboardSearchText = ""
316 var dashboardFilter: PortfolioFilterOption = .all 318 var dashboardFilter: PortfolioFilterOption = .all
317 var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings() 319 var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings()
@@ -432,6 +434,10 @@ final class DomainViewModel {
432 return false 434 return false
433 } 435 }
434 436
437 if let watchlistTagFilter, !trackedDomain.tags.contains(watchlistTagFilter) {
438 return false
439 }
440
435 switch watchlistFilter { 441 switch watchlistFilter {
436 case .all: 442 case .all:
437 return true 443 return true
@@ -888,6 +894,69 @@ final class DomainViewModel {
888 persistTrackedDomains() 894 persistTrackedDomains()
889 } 895 }
890 896
897 func updateTags(_ tags: [String], for trackedDomain: TrackedDomain) {
898 guard canEdit(trackedDomain) else { return }
899 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
900 let normalized = Self.normalizedTags(tags)
901 trackedDomains[index].tags = normalized
902 trackedDomains[index].updatedAt = Date()
903 persistTrackedDomains()
904 }
905
906 /// All tags currently in use across the watchlist, sorted for stable display.
907 var allWatchlistTags: [String] {
908 Array(Set(trackedDomains.flatMap(\.tags))).sorted()
909 }
910
911 func saveCurrentWatchlistView(name: String) {
912 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
913 guard !trimmedName.isEmpty else { return }
914 let view = WatchlistSavedView(
915 name: trimmedName,
916 tag: watchlistTagFilter,
917 filter: watchlistFilter,
918 sort: watchlistSortOption
919 )
920 watchlistSavedViews.append(view)
921 persistWatchlistSavedViews()
922 }
923
924 func applyWatchlistSavedView(_ view: WatchlistSavedView) {
925 watchlistTagFilter = view.tag
926 watchlistFilter = view.filter
927 watchlistSortOption = view.sort
928 }
929
930 func deleteWatchlistSavedViews(at offsets: IndexSet) {
931 watchlistSavedViews.remove(atOffsets: offsets)
932 persistWatchlistSavedViews()
933 }
934
935 private static let watchlistSavedViewsKey = "watchlistSavedViews"
936
937 private static func loadWatchlistSavedViews() -> [WatchlistSavedView] {
938 guard let data = UserDefaults.standard.data(forKey: watchlistSavedViewsKey),
939 let views = try? JSONDecoder().decode([WatchlistSavedView].self, from: data)
940 else { return [] }
941 return views
942 }
943
944 private func persistWatchlistSavedViews() {
945 guard let data = try? JSONEncoder().encode(watchlistSavedViews) else { return }
946 UserDefaults.standard.set(data, forKey: Self.watchlistSavedViewsKey)
947 }
948
949 private static func normalizedTags(_ tags: [String]) -> [String] {
950 var seen = Set<String>()
951 var normalized: [String] = []
952 for tag in tags {
953 let trimmed = tag.trimmingCharacters(in: .whitespacesAndNewlines)
954 guard !trimmed.isEmpty, seen.insert(trimmed.lowercased()).inserted else { continue }
955 normalized.append(trimmed)
956 }
957 return normalized.sorted()
958 }
959
891 func removeHistoryEntries(at offsets: IndexSet) { 960 func removeHistoryEntries(at offsets: IndexSet) {
892 history.remove(atOffsets: offsets) 961 history.remove(atOffsets: offsets)
893 persistHistory() 962 persistHistory()
DomainDig/Models.swift +31 −3
@@ -882,7 +882,7 @@ struct TimelineSection: Identifiable, Equatable {
882 let entries: [SnapshotSummary] 882 let entries: [SnapshotSummary]
883} 883}
884 884
885enum WatchlistFilterOption: String, CaseIterable, Identifiable { 885enum WatchlistFilterOption: String, CaseIterable, Identifiable, Codable {
886 case all 886 case all
887 case pinnedOnly 887 case pinnedOnly
888 case changedOnly 888 case changedOnly
@@ -901,7 +901,7 @@ enum WatchlistFilterOption: String, CaseIterable, Identifiable {
901 } 901 }
902} 902}
903 903
904enum WatchlistSortOption: String, CaseIterable, Identifiable { 904enum WatchlistSortOption: String, CaseIterable, Identifiable, Codable {
905 case pinned 905 case pinned
906 case recentlyUpdated 906 case recentlyUpdated
907 case alphabetical 907 case alphabetical
@@ -920,6 +920,30 @@ enum WatchlistSortOption: String, CaseIterable, Identifiable {
920 } 920 }
921} 921}
922 922
923/// A named watchlist filter preset (tag, filter, sort) the user can save and
924/// reapply. Stored locally in UserDefaults; not included in backup/restore.
925struct WatchlistSavedView: Codable, Identifiable, Equatable {
926 let id: UUID
927 var name: String
928 var tag: String?
929 var filter: WatchlistFilterOption
930 var sort: WatchlistSortOption
931
932 init(
933 id: UUID = UUID(),
934 name: String,
935 tag: String?,
936 filter: WatchlistFilterOption,
937 sort: WatchlistSortOption
938 ) {
939 self.id = id
940 self.name = name
941 self.tag = tag
942 self.filter = filter
943 self.sort = sort
944 }
945}
946
923enum PortfolioFilterOption: String, CaseIterable, Identifiable { 947enum PortfolioFilterOption: String, CaseIterable, Identifiable {
924 case all 948 case all
925 case healthy 949 case healthy
@@ -1258,6 +1282,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
1258 var monitoringState: MonitoringState 1282 var monitoringState: MonitoringState
1259 var pendingMonitoringAlerts: [MonitoringPendingAlert] 1283 var pendingMonitoringAlerts: [MonitoringPendingAlert]
1260 var collaboration: CollaborationMetadata? 1284 var collaboration: CollaborationMetadata?
1285 var tags: [String]
1261 1286
1262 init( 1287 init(
1263 id: UUID = UUID(), 1288 id: UUID = UUID(),
@@ -1277,7 +1302,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
1277 lastAlertAt: Date? = nil, 1302 lastAlertAt: Date? = nil,
1278 monitoringState: MonitoringState = MonitoringState(), 1303 monitoringState: MonitoringState = MonitoringState(),
1279 pendingMonitoringAlerts: [MonitoringPendingAlert] = [], 1304 pendingMonitoringAlerts: [MonitoringPendingAlert] = [],
1280 collaboration: CollaborationMetadata? = nil 1305 collaboration: CollaborationMetadata? = nil,
1306 tags: [String] = []
1281 ) { 1307 ) {
1282 self.id = id 1308 self.id = id
1283 self.domain = domain 1309 self.domain = domain
@@ -1297,6 +1323,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
1297 self.monitoringState = monitoringState 1323 self.monitoringState = monitoringState
1298 self.pendingMonitoringAlerts = pendingMonitoringAlerts 1324 self.pendingMonitoringAlerts = pendingMonitoringAlerts
1299 self.collaboration = collaboration 1325 self.collaboration = collaboration
1326 self.tags = tags
1300 } 1327 }
1301 1328
1302 init(from decoder: Decoder) throws { 1329 init(from decoder: Decoder) throws {
@@ -1319,6 +1346,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
1319 monitoringState = try container.decodeIfPresent(MonitoringState.self, forKey: .monitoringState) ?? MonitoringState() 1346 monitoringState = try container.decodeIfPresent(MonitoringState.self, forKey: .monitoringState) ?? MonitoringState()
1320 pendingMonitoringAlerts = try container.decodeIfPresent([MonitoringPendingAlert].self, forKey: .pendingMonitoringAlerts) ?? [] 1347 pendingMonitoringAlerts = try container.decodeIfPresent([MonitoringPendingAlert].self, forKey: .pendingMonitoringAlerts) ?? []
1321 collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration) 1348 collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration)
1349 tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
1322 } 1350 }
1323} 1351}
1324 1352
DomainDig/WatchlistView.swift +107
@@ -10,6 +10,9 @@ struct WatchlistView: View {
10 @State private var newTrackedDomain = "" 10 @State private var newTrackedDomain = ""
11 @State private var addDomainError: String? 11 @State private var addDomainError: String?
12 @FocusState private var isAddDomainFieldFocused: Bool 12 @FocusState private var isAddDomainFieldFocused: Bool
13 @State private var showSavedViewsSheet = false
14 @State private var showSaveViewPrompt = false
15 @State private var newSavedViewName = ""
13 16
14 private var pinnedDomains: [TrackedDomain] { 17 private var pinnedDomains: [TrackedDomain] {
15 viewModel.filteredTrackedDomains.filter(\.isPinned) 18 viewModel.filteredTrackedDomains.filter(\.isPinned)
@@ -23,6 +26,14 @@ struct WatchlistView: View {
23 let _ = purchaseService.currentTier 26 let _ = purchaseService.currentTier
24 27
25 List { 28 List {
29 if !viewModel.allWatchlistTags.isEmpty {
30 Section {
31 TagFilterChipRowView(tags: viewModel.allWatchlistTags, selection: $viewModel.watchlistTagFilter)
32 }
33 .listRowBackground(Color.clear)
34 .listRowInsets(EdgeInsets())
35 }
36
26 if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { 37 if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
27 Section("Refresh Progress") { 38 Section("Refresh Progress") {
28 VStack(alignment: .leading, spacing: 8) { 39 VStack(alignment: .leading, spacing: 8) {
@@ -110,6 +121,17 @@ struct WatchlistView: View {
110 } 121 }
111 } 122 }
112 123
124 Button("Save Current View…") {
125 newSavedViewName = ""
126 showSaveViewPrompt = true
127 }
128
129 if !viewModel.watchlistSavedViews.isEmpty {
130 Button("Saved Views") {
131 showSavedViewsSheet = true
132 }
133 }
134
113 Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") { 135 Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") {
114 AppHaptics.refresh() 136 AppHaptics.refresh()
115 viewModel.refreshAllTrackedDomains() 137 viewModel.refreshAllTrackedDomains()
@@ -211,6 +233,49 @@ struct WatchlistView: View {
211 availableDomains: viewModel.filteredTrackedDomains.map(\.domain) 233 availableDomains: viewModel.filteredTrackedDomains.map(\.domain)
212 ) 234 )
213 } 235 }
236 .alert("Save Current View", isPresented: $showSaveViewPrompt) {
237 TextField("View name", text: $newSavedViewName)
238 Button("Save") {
239 viewModel.saveCurrentWatchlistView(name: newSavedViewName)
240 }
241 Button("Cancel", role: .cancel) {}
242 } message: {
243 Text("Saves the current tag, filter, and sort as a reusable preset.")
244 }
245 .sheet(isPresented: $showSavedViewsSheet) {
246 NavigationStack {
247 List {
248 ForEach(viewModel.watchlistSavedViews) { view in
249 Button {
250 viewModel.applyWatchlistSavedView(view)
251 showSavedViewsSheet = false
252 } label: {
253 VStack(alignment: .leading, spacing: 2) {
254 Text(view.name)
255 .foregroundStyle(.primary)
256 Text([view.tag, view.filter.title, view.sort.title].compactMap { $0 }.joined(separator: " • "))
257 .font(.caption)
258 .foregroundStyle(.secondary)
259 }
260 }
261 }
262 .onDelete { offsets in
263 viewModel.deleteWatchlistSavedViews(at: offsets)
264 }
265 }
266 .navigationTitle("Saved Views")
267 .toolbar {
268 ToolbarItem(placement: .cancellationAction) {
269 Button("Done") {
270 showSavedViewsSheet = false
271 }
272 }
273 ToolbarItem(placement: .topBarTrailing) {
274 EditButton()
275 }
276 }
277 }
278 }
214 .preferredColorScheme(.dark) 279 .preferredColorScheme(.dark)
215 } 280 }
216 281
@@ -480,6 +545,8 @@ struct TrackedDomainDetailView: View {
480 545
481 @State private var noteDraft = "" 546 @State private var noteDraft = ""
482 @State private var isEditingNote = false 547 @State private var isEditingNote = false
548 @State private var tagsDraft = ""
549 @State private var isEditingTags = false
483 @State private var showRerunOptions = false 550 @State private var showRerunOptions = false
484 @State private var shareEntity: ShareableEntity? 551 @State private var shareEntity: ShareableEntity?
485 @State private var showingAuditTimeline = false 552 @State private var showingAuditTimeline = false
@@ -566,6 +633,14 @@ struct TrackedDomainDetailView: View {
566 } 633 }
567 .disabled(!viewModel.canEdit(liveTrackedDomain)) 634 .disabled(!viewModel.canEdit(liveTrackedDomain))
568 635
636 Button {
637 tagsDraft = liveTrackedDomain.tags.joined(separator: ", ")
638 isEditingTags = true
639 } label: {
640 Label(liveTrackedDomain.tags.isEmpty ? "Add Tags" : "Edit Tags", systemImage: "tag")
641 }
642 .disabled(!viewModel.canEdit(liveTrackedDomain))
643
569 Button { 644 Button {
570 shareEntity = .trackedDomain(liveTrackedDomain.domain) 645 shareEntity = .trackedDomain(liveTrackedDomain.domain)
571 } label: { 646 } label: {
@@ -574,6 +649,13 @@ struct TrackedDomainDetailView: View {
574 } 649 }
575 .listRowBackground(Color(.systemGray6).opacity(0.5)) 650 .listRowBackground(Color(.systemGray6).opacity(0.5))
576 651
652 if !liveTrackedDomain.tags.isEmpty {
653 Section("Tags") {
654 TagChipRowView(tags: liveTrackedDomain.tags)
655 }
656 .listRowBackground(Color(.systemGray6).opacity(0.5))
657 }
658
577 Section("Monitoring Status") { 659 Section("Monitoring Status") {
578 LabeledContent("State", value: viewModel.monitoringStatusLabel(for: liveTrackedDomain)) 660 LabeledContent("State", value: viewModel.monitoringStatusLabel(for: liveTrackedDomain))
579 LabeledContent("Current Interval", value: viewModel.monitoringIntervalLabel(for: liveTrackedDomain)) 661 LabeledContent("Current Interval", value: viewModel.monitoringIntervalLabel(for: liveTrackedDomain))
@@ -677,6 +759,31 @@ struct TrackedDomainDetailView: View {
677 } 759 }
678 } 760 }
679 } 761 }
762 .sheet(isPresented: $isEditingTags) {
763 NavigationStack {
764 Form {
765 Section("Tags") {
766 TextField("comma, separated, tags", text: $tagsDraft)
767 .textInputAutocapitalization(.never)
768 }
769 }
770 .navigationTitle("Edit Tags")
771 .toolbar {
772 ToolbarItem(placement: .cancellationAction) {
773 Button("Cancel") {
774 isEditingTags = false
775 }
776 }
777 ToolbarItem(placement: .confirmationAction) {
778 Button("Save") {
779 let tags = tagsDraft.components(separatedBy: ",")
780 viewModel.updateTags(tags, for: liveTrackedDomain)
781 isEditingTags = false
782 }
783 }
784 }
785 }
786 }
680 .sheet(item: $shareEntity) { entity in 787 .sheet(item: $shareEntity) { entity in
681 CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain) 788 CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain)
682 } 789 }