krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

31cbca674957eea7ae2529012e20a9d95158e561

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-13T02:33:14Z

feat: add ticket saved filters and label filtering

Implements: https://todo.sr.ht/~ccleberg/hutch/32
Implements: https://todo.sr.ht/~ccleberg/hutch/33
 Hutch/App/AppStorageKeys.swift                   |   2 +
 Hutch/Views/Builds/BuildTaskLogView.swift        |   4 +-
 Hutch/Views/Tickets/TicketListView.swift         | 245 ++++++++++++++++++++++-
 Hutch/Views/Tickets/TicketListViewModel.swift    | 178 +++++++++++++---
 Hutch/Views/Tickets/TicketSavedFilterStore.swift | 142 +++++++++++++
 HutchTests/TicketListViewModelTests.swift        |  72 ++++---
 HutchTests/TicketSavedFilterStoreTests.swift     |  88 ++++++++
 7 files changed, 670 insertions(+), 61 deletions(-)

diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift
index 9a97657..5bb8307 100644
--- a/Hutch/App/AppStorageKeys.swift
+++ b/Hutch/App/AppStorageKeys.swift
@@ -14,6 +14,8 @@ enum AppStorageKeys {
     static let homeBuildsExpanded = "homeBuildsExpanded"
     static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval"
     static let buildsRepoFilter = "buildsRepoFilter"
+    static let ticketFilterState = "ticketFilterState"
+    static let ticketSavedFilters = "ticketSavedFilters"
     static let appTheme = "appTheme"
     static let displayDensity = "displayDensity"
 }
diff --git a/Hutch/Views/Builds/BuildTaskLogView.swift b/Hutch/Views/Builds/BuildTaskLogView.swift
index 69353bf..fda69f6 100644
--- a/Hutch/Views/Builds/BuildTaskLogView.swift
+++ b/Hutch/Views/Builds/BuildTaskLogView.swift
@@ -407,7 +407,7 @@ private struct LogScrollTarget: Equatable {
     let range: LogTextRange
 }
 
-func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogTextRange] {
+nonisolated func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogTextRange] {
     let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
     guard !trimmedQuery.isEmpty else { return [] }
 
@@ -433,7 +433,7 @@ func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogT
     return matches
 }
 
-func detectLogAnchors(in text: String, limit: Int = 24) -> [LogAnchor] {
+nonisolated func detectLogAnchors(in text: String, limit: Int = 24) -> [LogAnchor] {
     let nsText = text as NSString
     let strongMarkers = [
         "fatal error",
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
index 8b5fc03..fffaba4 100644
--- a/Hutch/Views/Tickets/TicketListView.swift
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -13,6 +13,8 @@ struct TicketListView: View {
     @State private var showCreateTicketSheet = false
     @State private var createdTicket: TicketSummary?
     @State private var labelEditorTicket: LabelEditorTicket?
+    @State private var showLabelFilterSheet = false
+    @State private var showSaveFilterSheet = false
     @State private var showTrackerEditor = false
     @State private var showTrackerACLs = false
     @State private var showTrackerLabels = false
@@ -86,6 +88,18 @@ struct TicketListView: View {
                 .presentationDetents([.medium])
             }
         }
+        .sheet(isPresented: $showLabelFilterSheet) {
+            if let viewModel {
+                TicketFilterLabelsSheet(viewModel: viewModel)
+                    .presentationDetents([.medium, .large])
+            }
+        }
+        .sheet(isPresented: $showSaveFilterSheet) {
+            if let viewModel {
+                SaveTicketFilterSheet(viewModel: viewModel)
+                    .presentationDetents([.height(220)])
+            }
+        }
         .sheet(isPresented: $showTrackerEditor) {
             if let trackerManagementViewModel {
                 TrackerEditorSheet(
@@ -169,6 +183,7 @@ struct TicketListView: View {
                 viewModel = vm
                 trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client)
                 await vm.loadTickets()
+                await vm.loadTrackerLabels()
             }
         }
     }
@@ -178,14 +193,33 @@ struct TicketListView: View {
         @Bindable var vm = viewModel
 
         List {
-            // Filter picker
             Section {
-                Picker("Filter", selection: $vm.filter) {
-                    ForEach(TicketFilter.allCases, id: \.self) { filter in
-                        Text(filter.rawValue).tag(filter)
+                VStack(alignment: .leading, spacing: 12) {
+                    Picker("Filter", selection: $vm.filter) {
+                        ForEach(TicketFilter.allCases, id: \.self) { filter in
+                            Text(filter.rawValue).tag(filter)
+                        }
+                    }
+                    .pickerStyle(.segmented)
+
+                    TicketQuickFilterBar(
+                        selectedLabels: viewModel.selectedLabels,
+                        savedFilters: viewModel.savedFilters,
+                        activeSavedFilterID: viewModel.activeSavedFilterID,
+                        canSaveCurrentFilter: viewModel.hasCustomFilterSelection
+                    ) {
+                        showLabelFilterSheet = true
+                    } onSaveFilter: {
+                        showSaveFilterSheet = true
+                    } onResetFilters: {
+                        vm.resetFilters()
+                    } onApplySavedFilter: { savedFilter in
+                        vm.applySavedFilter(savedFilter)
+                    } onDeleteSavedFilter: { savedFilter in
+                        vm.deleteSavedFilter(savedFilter)
                     }
                 }
-                .pickerStyle(.segmented)
+                .padding(.vertical, 4)
                 .listRowBackground(Color.clear)
                 .listRowInsets(EdgeInsets())
             }
@@ -250,7 +284,7 @@ struct TicketListView: View {
                 ContentUnavailableView(
                     "No Tickets",
                     systemImage: "ticket",
-                    description: Text("No \(viewModel.filter.rawValue.lowercased()) tickets found.")
+                    description: Text(emptyStateDescription(for: viewModel))
                 )
             }
         }
@@ -263,6 +297,13 @@ struct TicketListView: View {
         }
     }
 
+    private func emptyStateDescription(for viewModel: TicketListViewModel) -> String {
+        if !viewModel.selectedLabelIDs.isEmpty {
+            return "No \(viewModel.filter.rawValue.lowercased()) tickets found for the selected labels."
+        }
+        return "No \(viewModel.filter.rawValue.lowercased()) tickets found."
+    }
+
     private var trackerActionsMenu: some View {
         Menu {
             Button {
@@ -497,6 +538,198 @@ private struct TicketLabelsSheet: View {
     }
 }
 
+private struct TicketQuickFilterBar: View {
+    let selectedLabels: [TicketLabel]
+    let savedFilters: [SavedTicketFilter]
+    let activeSavedFilterID: SavedTicketFilter.ID?
+    let canSaveCurrentFilter: Bool
+    let onShowLabels: () -> Void
+    let onSaveFilter: () -> Void
+    let onResetFilters: () -> Void
+    let onApplySavedFilter: (SavedTicketFilter) -> Void
+    let onDeleteSavedFilter: (SavedTicketFilter) -> Void
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 10) {
+            HStack(spacing: 8) {
+                Button(action: onShowLabels) {
+                    Label(labelButtonTitle, systemImage: "tag")
+                        .font(.caption.weight(.medium))
+                }
+                .buttonStyle(.bordered)
+
+                Button(action: onSaveFilter) {
+                    Label("Save Filter", systemImage: "square.and.arrow.down")
+                        .font(.caption.weight(.medium))
+                }
+                .buttonStyle(.bordered)
+                .disabled(!canSaveCurrentFilter)
+
+                if canSaveCurrentFilter {
+                    Button("Reset", action: onResetFilters)
+                        .font(.caption.weight(.medium))
+                        .buttonStyle(.bordered)
+                }
+            }
+
+            if !selectedLabels.isEmpty {
+                ScrollView(.horizontal, showsIndicators: false) {
+                    HStack(spacing: 8) {
+                        ForEach(selectedLabels) { label in
+                            LabelPill(label: label)
+                        }
+                    }
+                }
+            }
+
+            if !savedFilters.isEmpty {
+                ScrollView(.horizontal, showsIndicators: false) {
+                    HStack(spacing: 8) {
+                        ForEach(savedFilters) { savedFilter in
+                            Button {
+                                onApplySavedFilter(savedFilter)
+                            } label: {
+                                HStack(spacing: 6) {
+                                    Image(systemName: activeSavedFilterID == savedFilter.id ? "checkmark.circle.fill" : "line.3.horizontal.decrease.circle")
+                                        .imageScale(.small)
+                                    Text(savedFilter.name)
+                                        .lineLimit(1)
+                                }
+                                .font(.caption.weight(.medium))
+                                .padding(.horizontal, 10)
+                                .padding(.vertical, 7)
+                                .foregroundStyle(activeSavedFilterID == savedFilter.id ? Color.accentColor : Color.primary)
+                                .background(
+                                    activeSavedFilterID == savedFilter.id ?
+                                    Color.accentColor.opacity(0.14) :
+                                    Color(.secondarySystemFill),
+                                    in: Capsule()
+                                )
+                            }
+                            .contextMenu {
+                                Button(role: .destructive) {
+                                    onDeleteSavedFilter(savedFilter)
+                                } label: {
+                                    Label("Delete Filter", systemImage: "trash")
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private var labelButtonTitle: String {
+        selectedLabels.isEmpty ? "Labels" : "Labels (\(selectedLabels.count))"
+    }
+}
+
+private struct TicketFilterLabelsSheet: View {
+    let viewModel: TicketListViewModel
+
+    @Environment(\.dismiss) private var dismiss
+
+    var body: some View {
+        NavigationStack {
+            Group {
+                if viewModel.availableLabels.isEmpty {
+                    ContentUnavailableView(
+                        "No Labels",
+                        systemImage: "tag",
+                        description: Text("This tracker has no labels available for filtering yet.")
+                    )
+                } else {
+                    List {
+                        if !viewModel.selectedLabels.isEmpty {
+                            Section("Selected") {
+                                FlowLayout(spacing: 6) {
+                                    ForEach(viewModel.selectedLabels) { label in
+                                        LabelPill(label: label)
+                                    }
+                                }
+                                .padding(.vertical, 4)
+                            }
+                        }
+
+                        Section("Labels") {
+                            ForEach(viewModel.availableLabels) { label in
+                                Button {
+                                    viewModel.toggleLabelSelection(label)
+                                } label: {
+                                    HStack {
+                                        LabelPill(label: label)
+                                        Spacer()
+                                        if viewModel.selectedLabelIDs.contains(label.id) {
+                                            Image(systemName: "checkmark")
+                                                .foregroundStyle(.blue)
+                                        }
+                                    }
+                                }
+                                .buttonStyle(.plain)
+                            }
+                        }
+                    }
+                }
+            }
+            .navigationTitle("Filter Labels")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Done") { dismiss() }
+                }
+                if !viewModel.selectedLabelIDs.isEmpty {
+                    ToolbarItem(placement: .confirmationAction) {
+                        Button("Clear") { viewModel.clearLabelSelection() }
+                    }
+                }
+            }
+            .task {
+                if viewModel.availableLabels.isEmpty {
+                    await viewModel.loadTrackerLabels()
+                }
+            }
+        }
+    }
+}
+
+private struct SaveTicketFilterSheet: View {
+    let viewModel: TicketListViewModel
+
+    @Environment(\.dismiss) private var dismiss
+    @State private var name: String
+
+    init(viewModel: TicketListViewModel) {
+        self.viewModel = viewModel
+        self._name = State(initialValue: viewModel.suggestedSavedFilterName)
+    }
+
+    var body: some View {
+        NavigationStack {
+            Form {
+                Section("Name") {
+                    TextField("Filter name", text: $name)
+                        .textInputAutocapitalization(.words)
+                }
+            }
+            .navigationTitle("Save Filter")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Cancel") { dismiss() }
+                }
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Save") {
+                        viewModel.saveCurrentFilter(named: name)
+                        dismiss()
+                    }
+                    .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+                }
+            }
+        }
+    }
+}
+
 private struct TicketListLabelToggleRow: View {
     let label: TicketLabel
     let isApplied: Bool
diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift
index 9f2fe79..3b1310d 100644
--- a/Hutch/Views/Tickets/TicketListViewModel.swift
+++ b/Hutch/Views/Tickets/TicketListViewModel.swift
@@ -55,7 +55,7 @@ private struct MutationEventRef: Decodable, Sendable {
 
 // MARK: - Filter
 
-enum TicketFilter: String, CaseIterable, Sendable {
+enum TicketFilter: String, CaseIterable, Codable, Sendable {
     case open = "Open"
     case resolved = "Resolved"
     case all = "All"
@@ -77,32 +77,46 @@ final class TicketListViewModel {
     private(set) var isCreatingTicket = false
     private(set) var isPerformingAction = false
     private(set) var trackerLabels: [TicketLabel] = []
+    private(set) var savedFilters: [SavedTicketFilter]
     var error: String?
     var filter: TicketFilter = .open {
         didSet {
-            UserDefaults.standard.set(filter.rawValue, forKey: filterDefaultsKey)
+            persistFilterState()
+        }
+    }
+    var selectedLabelIDs: Set<Int> = [] {
+        didSet {
+            persistFilterState()
         }
     }
     var searchText = ""
+    private(set) var activeSavedFilterID: SavedTicketFilter.ID?
 
     private var cursor: String?
     private var hasMore = true
     private let client: SRHTClient
-
-    private var filterDefaultsKey: String {
-        "ticketFilter_\(trackerRid)"
-    }
-
-    init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, client: SRHTClient) {
+    private let defaults: UserDefaults
+
+    init(
+        ownerUsername: String,
+        trackerName: String,
+        trackerId: Int,
+        trackerRid: String,
+        client: SRHTClient,
+        defaults: UserDefaults = .standard
+    ) {
         self.ownerUsername = ownerUsername
         self.trackerName = trackerName
         self.trackerId = trackerId
         self.trackerRid = trackerRid
         self.client = client
-        if let raw = UserDefaults.standard.string(forKey: filterDefaultsKey),
-           let restored = TicketFilter(rawValue: raw) {
-            self.filter = restored
-        }
+        self.defaults = defaults
+
+        let restoredState = TicketSavedFilterStore.loadCurrentState(for: trackerRid, defaults: defaults)
+        self.filter = restoredState.status
+        self.selectedLabelIDs = Set(restoredState.labelIDs)
+        self.savedFilters = TicketSavedFilterStore.loadSavedFilters(for: trackerRid, defaults: defaults)
+        self.activeSavedFilterID = self.savedFilters.first(where: { $0.state == restoredState })?.id
     }
 
     // MARK: - Query
@@ -186,25 +200,43 @@ final class TicketListViewModel {
 
     // MARK: - Computed
 
-    /// Tickets filtered by the selected status filter.
-    var filteredTickets: [TicketSummary] {
-        let statusFiltered: [TicketSummary]
-        switch filter {
-        case .open:
-            statusFiltered = tickets.filter { $0.status.isOpen }
-        case .resolved:
-            statusFiltered = tickets.filter { !$0.status.isOpen }
-        case .all:
-            statusFiltered = tickets
+    var currentFilterState: TicketListFilterState {
+        TicketListFilterState(status: filter, labelIDs: Array(selectedLabelIDs))
+    }
+
+    var availableLabels: [TicketLabel] {
+        let combinedLabels = trackerLabels + tickets.flatMap(\.labels)
+        let deduplicated = combinedLabels.reduce(into: [Int: TicketLabel]()) { partialResult, label in
+            partialResult[label.id] = label
         }
-        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
-        guard !q.isEmpty else { return statusFiltered }
-        return statusFiltered.filter {
-            String($0.id).contains(q) ||
-            $0.title.lowercased().contains(q) ||
-            $0.submitter.canonicalName.lowercased().contains(q) ||
-            $0.labels.contains { $0.name.lowercased().contains(q) }
+        return deduplicated.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
+    }
+
+    var selectedLabels: [TicketLabel] {
+        availableLabels.filter { selectedLabelIDs.contains($0.id) }
+    }
+
+    var suggestedSavedFilterName: String {
+        let labelNames = selectedLabels.map(\.name).sorted()
+        var components: [String] = []
+
+        if filter != .open || !labelNames.isEmpty {
+            components.append(filter.rawValue)
+        }
+        if !labelNames.isEmpty {
+            components.append(labelNames.joined(separator: ", "))
         }
+
+        return components.isEmpty ? "Open Tickets" : components.joined(separator: " • ")
+    }
+
+    var hasCustomFilterSelection: Bool {
+        !currentFilterState.isDefault
+    }
+
+    /// Tickets filtered by the selected status and label filters.
+    var filteredTickets: [TicketSummary] {
+        Self.filterTickets(tickets, state: currentFilterState, query: searchText)
     }
 
     // MARK: - Public API
@@ -401,6 +433,54 @@ final class TicketListViewModel {
         }
     }
 
+    func toggleLabelSelection(_ label: TicketLabel) {
+        if selectedLabelIDs.contains(label.id) {
+            selectedLabelIDs.remove(label.id)
+        } else {
+            selectedLabelIDs.insert(label.id)
+        }
+    }
+
+    func clearLabelSelection() {
+        selectedLabelIDs = []
+    }
+
+    func resetFilters() {
+        filter = .open
+        selectedLabelIDs = []
+    }
+
+    func applySavedFilter(_ savedFilter: SavedTicketFilter) {
+        filter = savedFilter.state.status
+        selectedLabelIDs = Set(savedFilter.state.labelIDs)
+        activeSavedFilterID = savedFilter.id
+    }
+
+    func saveCurrentFilter(named name: String) {
+        guard let savedFilter = TicketSavedFilterStore.saveFilter(
+            named: name,
+            state: currentFilterState,
+            for: trackerRid,
+            defaults: defaults
+        ) else {
+            return
+        }
+
+        savedFilters.removeAll {
+            $0.name.compare(name, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
+        }
+        savedFilters.insert(savedFilter, at: 0)
+        activeSavedFilterID = savedFilter.id
+    }
+
+    func deleteSavedFilter(_ savedFilter: SavedTicketFilter) {
+        TicketSavedFilterStore.deleteFilter(id: savedFilter.id, for: trackerRid, defaults: defaults)
+        savedFilters.removeAll { $0.id == savedFilter.id }
+        if activeSavedFilterID == savedFilter.id {
+            activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id
+        }
+    }
+
     func labelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
         guard !isPerformingAction else { return }
         isPerformingAction = true
@@ -569,4 +649,44 @@ final class TicketListViewModel {
             assignees: ticket.assignees
         )
     }
+
+    private func persistFilterState() {
+        TicketSavedFilterStore.saveCurrentState(currentFilterState, for: trackerRid, defaults: defaults)
+        activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id
+    }
+
+    static func filterTickets(
+        _ tickets: [TicketSummary],
+        state: TicketListFilterState,
+        query: String
+    ) -> [TicketSummary] {
+        let statusFiltered: [TicketSummary]
+        switch state.status {
+        case .open:
+            statusFiltered = tickets.filter { $0.status.isOpen }
+        case .resolved:
+            statusFiltered = tickets.filter { !$0.status.isOpen }
+        case .all:
+            statusFiltered = tickets
+        }
+
+        let labelFiltered: [TicketSummary]
+        if state.labelIDs.isEmpty {
+            labelFiltered = statusFiltered
+        } else {
+            let selectedLabelIDs = Set(state.labelIDs)
+            labelFiltered = statusFiltered.filter { ticket in
+                !selectedLabelIDs.isDisjoint(with: ticket.labels.map(\.id))
+            }
+        }
+
+        let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        guard !q.isEmpty else { return labelFiltered }
+        return labelFiltered.filter {
+            String($0.id).contains(q) ||
+            $0.title.lowercased().contains(q) ||
+            $0.submitter.canonicalName.lowercased().contains(q) ||
+            $0.labels.contains { $0.name.lowercased().contains(q) }
+        }
+    }
 }
diff --git a/Hutch/Views/Tickets/TicketSavedFilterStore.swift b/Hutch/Views/Tickets/TicketSavedFilterStore.swift
new file mode 100644
index 0000000..f5e5946
--- /dev/null
+++ b/Hutch/Views/Tickets/TicketSavedFilterStore.swift
@@ -0,0 +1,142 @@
+import Foundation
+
+struct TicketListFilterState: Codable, Hashable, Sendable {
+    var status: TicketFilter
+    var labelIDs: [Int]
+
+    init(status: TicketFilter = .open, labelIDs: [Int] = []) {
+        self.status = status
+        self.labelIDs = Array(Set(labelIDs)).sorted()
+    }
+
+    var isDefault: Bool {
+        status == .open && labelIDs.isEmpty
+    }
+}
+
+struct SavedTicketFilter: Codable, Hashable, Identifiable, Sendable {
+    let id: UUID
+    let name: String
+    let state: TicketListFilterState
+    let createdAt: Date
+}
+
+enum TicketSavedFilterStore {
+    static func loadCurrentState(
+        for trackerID: String,
+        defaults: UserDefaults = .standard
+    ) -> TicketListFilterState {
+        let allStates = loadStates(defaults: defaults)
+        if let savedState = allStates[trackerID] {
+            return savedState
+        }
+
+        if let legacyRawValue = defaults.string(forKey: legacyStatusKey(for: trackerID)),
+           let status = TicketFilter(rawValue: legacyRawValue) {
+            return TicketListFilterState(status: status)
+        }
+
+        return TicketListFilterState()
+    }
+
+    static func saveCurrentState(
+        _ state: TicketListFilterState,
+        for trackerID: String,
+        defaults: UserDefaults = .standard
+    ) {
+        var allStates = loadStates(defaults: defaults)
+        allStates[trackerID] = state
+        save(allStates, key: AppStorageKeys.ticketFilterState, defaults: defaults)
+        defaults.removeObject(forKey: legacyStatusKey(for: trackerID))
+    }
+
+    static func loadSavedFilters(
+        for trackerID: String,
+        defaults: UserDefaults = .standard
+    ) -> [SavedTicketFilter] {
+        let allFilters: [String: [SavedTicketFilter]] = loadDictionary(
+            key: AppStorageKeys.ticketSavedFilters,
+            defaults: defaults
+        )
+        return allFilters[trackerID] ?? []
+    }
+
+    static func saveFilter(
+        named name: String,
+        state: TicketListFilterState,
+        for trackerID: String,
+        defaults: UserDefaults = .standard,
+        now: Date = .now
+    ) -> SavedTicketFilter? {
+        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedName.isEmpty else { return nil }
+
+        var allFilters: [String: [SavedTicketFilter]] = loadDictionary(
+            key: AppStorageKeys.ticketSavedFilters,
+            defaults: defaults
+        )
+        var trackerFilters = allFilters[trackerID] ?? []
+        trackerFilters.removeAll {
+            $0.name.compare(trimmedName, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
+        }
+
+        let savedFilter = SavedTicketFilter(
+            id: UUID(),
+            name: trimmedName,
+            state: state,
+            createdAt: now
+        )
+        trackerFilters.insert(savedFilter, at: 0)
+        allFilters[trackerID] = trackerFilters
+        save(allFilters, key: AppStorageKeys.ticketSavedFilters, defaults: defaults)
+        return savedFilter
+    }
+
+    static func deleteFilter(
+        id: SavedTicketFilter.ID,
+        for trackerID: String,
+        defaults: UserDefaults = .standard
+    ) {
+        var allFilters: [String: [SavedTicketFilter]] = loadDictionary(
+            key: AppStorageKeys.ticketSavedFilters,
+            defaults: defaults
+        )
+        var trackerFilters = allFilters[trackerID] ?? []
+        trackerFilters.removeAll { $0.id == id }
+        allFilters[trackerID] = trackerFilters
+        save(allFilters, key: AppStorageKeys.ticketSavedFilters, defaults: defaults)
+    }
+
+    private static func loadStates(defaults: UserDefaults) -> [String: TicketListFilterState] {
+        loadDictionary(key: AppStorageKeys.ticketFilterState, defaults: defaults)
+    }
+
+    private static func loadDictionary<T: Decodable>(
+        key: String,
+        defaults: UserDefaults
+    ) -> [String: T] {
+        guard let data = defaults.data(forKey: key) else {
+            return [:]
+        }
+
+        do {
+            return try JSONDecoder().decode([String: T].self, from: data)
+        } catch {
+            defaults.removeObject(forKey: key)
+            return [:]
+        }
+    }
+
+    private static func save<T: Encodable>(
+        _ value: [String: T],
+        key: String,
+        defaults: UserDefaults
+    ) {
+        guard let data = try? JSONEncoder().encode(value) else { return }
+        defaults.set(data, forKey: key)
+    }
+
+    private static func legacyStatusKey(for trackerID: String) -> String {
+        "ticketFilter_\(trackerID)"
+    }
+}
diff --git a/HutchTests/TicketListViewModelTests.swift b/HutchTests/TicketListViewModelTests.swift
index 23be24c..0f42bf8 100644
--- a/HutchTests/TicketListViewModelTests.swift
+++ b/HutchTests/TicketListViewModelTests.swift
@@ -11,7 +11,11 @@ struct TicketListViewModelTests {
             makeTicket(id: 2, title: "Already fixed", status: .resolved, submitter: "~owner", labels: [])
         ]
 
-        let filtered = filterTickets(tickets, filter: .open, query: "")
+        let filtered = filterTickets(
+            tickets,
+            state: TicketListFilterState(status: .open),
+            query: ""
+        )
 
         #expect(filtered.map(\.id) == [1])
     }
@@ -23,8 +27,16 @@ struct TicketListViewModelTests {
             makeTicket(id: 99, title: "Settings polish", status: .reported, submitter: "~owner", labels: [])
         ]
 
-        let titleMatches = filterTickets(tickets, filter: .all, query: "settings")
-        let idMatches = filterTickets(tickets, filter: .all, query: "42")
+        let titleMatches = filterTickets(
+            tickets,
+            state: TicketListFilterState(status: .all),
+            query: "settings"
+        )
+        let idMatches = filterTickets(
+            tickets,
+            state: TicketListFilterState(status: .all),
+            query: "42"
+        )
 
         #expect(titleMatches.map(\.id) == [99])
         #expect(idMatches.map(\.id) == [42])
@@ -37,13 +49,38 @@ struct TicketListViewModelTests {
             makeTicket(id: 2, title: "Needs triage", status: .reported, submitter: "~triage", labels: [makeLabel(id: 2, name: "needs-info")])
         ]
 
-        let submitterMatches = filterTickets(tickets, filter: .all, query: "~triage")
-        let labelMatches = filterTickets(tickets, filter: .all, query: "bug")
+        let submitterMatches = filterTickets(
+            tickets,
+            state: TicketListFilterState(status: .all),
+            query: "~triage"
+        )
+        let labelMatches = filterTickets(
+            tickets,
+            state: TicketListFilterState(status: .all),
+            query: "bug"
+        )
 
         #expect(submitterMatches.map(\.id) == [2])
         #expect(labelMatches.map(\.id) == [1])
     }
 
+    @Test
+    func filteredTicketsMatchesAnySelectedLabel() {
+        let tickets = [
+            makeTicket(id: 1, title: "Crash on launch", status: .reported, submitter: "~owner", labels: [makeLabel(id: 1, name: "bug")]),
+            makeTicket(id: 2, title: "Needs triage", status: .reported, submitter: "~triage", labels: [makeLabel(id: 2, name: "needs-info")]),
+            makeTicket(id: 3, title: "Unlabeled", status: .reported, submitter: "~owner", labels: [])
+        ]
+
+        let filtered = filterTickets(
+            tickets,
+            state: TicketListFilterState(status: .all, labelIDs: [2, 3]),
+            query: ""
+        )
+
+        #expect(filtered.map(\.id) == [2])
+    }
+
     @Test
     @MainActor
     func resolveTicketInputHasCorrectStatusAndDefaultResolution() {
@@ -65,25 +102,12 @@ struct TicketListViewModelTests {
         #expect(input["resolution"] == nil)
     }
 
-    private func filterTickets(_ tickets: [TicketSummary], filter: TicketFilter, query: String) -> [TicketSummary] {
-        let statusFiltered: [TicketSummary]
-        switch filter {
-        case .open:
-            statusFiltered = tickets.filter { $0.status.isOpen }
-        case .resolved:
-            statusFiltered = tickets.filter { !$0.status.isOpen }
-        case .all:
-            statusFiltered = tickets
-        }
-
-        let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
-        guard !q.isEmpty else { return statusFiltered }
-        return statusFiltered.filter {
-            String($0.id).contains(q) ||
-            $0.title.lowercased().contains(q) ||
-            $0.submitter.canonicalName.lowercased().contains(q) ||
-            $0.labels.contains { $0.name.lowercased().contains(q) }
-        }
+    private func filterTickets(
+        _ tickets: [TicketSummary],
+        state: TicketListFilterState,
+        query: String
+    ) -> [TicketSummary] {
+        TicketListViewModel.filterTickets(tickets, state: state, query: query)
     }
 
     private func makeTicket(
diff --git a/HutchTests/TicketSavedFilterStoreTests.swift b/HutchTests/TicketSavedFilterStoreTests.swift
new file mode 100644
index 0000000..2ff64e6
--- /dev/null
+++ b/HutchTests/TicketSavedFilterStoreTests.swift
@@ -0,0 +1,88 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct TicketSavedFilterStoreTests {
+
+    @Test
+    func storesCurrentFilterStatePerTracker() {
+        let suiteName = "TicketSavedFilterStoreTests-\(#function)-\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: suiteName)!
+        defer { defaults.removePersistentDomain(forName: suiteName) }
+
+        TicketSavedFilterStore.saveCurrentState(
+            TicketListFilterState(status: .all, labelIDs: [4, 1]),
+            for: "tracker-a",
+            defaults: defaults
+        )
+        TicketSavedFilterStore.saveCurrentState(
+            TicketListFilterState(status: .resolved, labelIDs: [9]),
+            for: "tracker-b",
+            defaults: defaults
+        )
+
+        #expect(
+            TicketSavedFilterStore.loadCurrentState(for: "tracker-a", defaults: defaults) ==
+            TicketListFilterState(status: .all, labelIDs: [1, 4])
+        )
+        #expect(
+            TicketSavedFilterStore.loadCurrentState(for: "tracker-b", defaults: defaults) ==
+            TicketListFilterState(status: .resolved, labelIDs: [9])
+        )
+    }
+
+    @Test
+    func savesNamedFiltersPerTrackerAndReplacesDuplicateNames() {
+        let suiteName = "TicketSavedFilterStoreTests-\(#function)-\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: suiteName)!
+        defer { defaults.removePersistentDomain(forName: suiteName) }
+
+        _ = TicketSavedFilterStore.saveFilter(
+            named: "Bugs",
+            state: TicketListFilterState(status: .open, labelIDs: [1]),
+            for: "tracker-a",
+            defaults: defaults,
+            now: Date(timeIntervalSince1970: 100)
+        )
+        _ = TicketSavedFilterStore.saveFilter(
+            named: "bugs",
+            state: TicketListFilterState(status: .resolved, labelIDs: [2]),
+            for: "tracker-a",
+            defaults: defaults,
+            now: Date(timeIntervalSince1970: 200)
+        )
+        _ = TicketSavedFilterStore.saveFilter(
+            named: "Needs Info",
+            state: TicketListFilterState(status: .all, labelIDs: [3]),
+            for: "tracker-b",
+            defaults: defaults,
+            now: Date(timeIntervalSince1970: 300)
+        )
+
+        let trackerAFilters = TicketSavedFilterStore.loadSavedFilters(for: "tracker-a", defaults: defaults)
+        let trackerBFilters = TicketSavedFilterStore.loadSavedFilters(for: "tracker-b", defaults: defaults)
+
+        #expect(trackerAFilters.count == 1)
+        #expect(trackerAFilters.first?.name == "bugs")
+        #expect(trackerAFilters.first?.state == TicketListFilterState(status: .resolved, labelIDs: [2]))
+        #expect(trackerBFilters.map(\.name) == ["Needs Info"])
+    }
+
+    @Test
+    func deletesSavedFilter() {
+        let suiteName = "TicketSavedFilterStoreTests-\(#function)-\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: suiteName)!
+        defer { defaults.removePersistentDomain(forName: suiteName) }
+
+        let savedFilter = TicketSavedFilterStore.saveFilter(
+            named: "Bugs",
+            state: TicketListFilterState(status: .open, labelIDs: [1]),
+            for: "tracker-a",
+            defaults: defaults
+        )
+
+        TicketSavedFilterStore.deleteFilter(id: savedFilter!.id, for: "tracker-a", defaults: defaults)
+
+        #expect(TicketSavedFilterStore.loadSavedFilters(for: "tracker-a", defaults: defaults).isEmpty)
+    }
+}