transit PR #227 head 9670d06 commits 9 diff +424 / -35 threads 0 open

PR #227 Ready: T-1825 milestone filter

Exact reviewed head 9670d060d8f806d647fb5147460c354505a83720. The dashboard filter now remains current across external milestone mutations without broadening terminal milestones into normal picker choices.

At a glance

  • @Query observes all milestones so MCP, CloudKit, status, and deletion changes refresh the presented filter.
  • Open choices remain first; only selected terminal rows are appended, in the exact order users see.
  • Selected rows use the native accessibility selected trait; labels contain the title and terminal status once.
  • Focused macOS and iOS regressions, lint, and both-platform builds pass.

Verdict

Ready

Independent exact-head review returned No findings; GitHub's claude-review check completed successfully; and the PR has zero unresolved review threads. The report records the separate full-suite harness limitations and UI failures without claiming they were baselined.

Review findings

3 raised · 3 fixed · 0 skipped

Jump to findings →

Commits

Three-level explanation

A terminal milestone used to disappear from the filter even though it still affected the board. The menu now keeps that selected item visible until the user removes it, and it updates when another device or automation changes the milestone.

MilestoneFilterMenu observes all Milestone models with @Query, scopes the dynamic project set in memory, builds an open-first/selected-terminal union, and orders it by the displayed title. Focused Swift Testing regressions cover status transitions, deletion/Clear, project scope, title order, and presentation lifecycle.

The unpredicated SwiftData query is CloudKit-safe and avoids optional relationship/set-membership predicate limitations. Derived state keeps the filter host alive during presentation but dismisses it when a local or external mutation leaves no options and no selection. UUIDs provide deterministic deduplication and title-sort tie-breaking without runtime traps on duplicate keys.

Important changes — detailed

Milestone filter observes live SwiftData records

Transit/Transit/Views/Dashboard/MilestoneFilterMenu.swift

Why it matters. External MCP, CloudKit, status, and deletion mutations update a currently presented filter instead of leaving a stale snapshot.

What to look at. MilestoneFilterMenu query and presentation lifecycle

Takeaway. Use a model query for view state that must react to out-of-band persistence changes; scope dynamic relationship selections in memory.
Rationale. Selected terminal records must remain removable while normal picker choices stay open-only.

Visible options use displayed-title ordering

Transit/Transit/Views/Dashboard/MilestoneFilterMenu.swift

Why it matters. Multi-project menus now sort by the label users see, retain open-first terminal-suffix placement, and remain deterministic.

What to look at. availableMilestones and orderedMilestones

Takeaway. Sort by the rendered user-facing key when display formatting changes the apparent order.
Rationale. Project-prefixed titles in multi-project scope must not appear out of order.

Focused transition and lifecycle regressions

Transit/TransitTests/MilestoneFilterMenuTests.swift

Why it matters. Prevents terminal-option broadening, stale selection loss, deleted-selection dead ends, and blank presented-menu states.

What to look at. MilestoneFilterMenuTests

Takeaway. Model active-selection transitions and presentation lifecycle separately from normal option eligibility.
Rationale. The bug stems from an existing selection crossing into a state that is invalid for new choices.

Review findings

SeverityAreaFindingResolution
majorfilter observationService snapshot did not update an already-open menu after external mutations.Replaced it with an all-milestone @Query and CloudKit-safe in-memory project scope.
majoraccessibilityOpen and terminal selected rows did not communicate selection state consistently.Applied a native selected trait uniformly while keeping label content non-duplicative.
majorordering and lifecycleIndependent review found displayed-title ordering and presented-menu lifecycle edge cases.Sorted by rendered title, centralized visibility logic, and dismissed only an otherwise empty presentation.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +1 / -1
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex c15eae7..04e4e3a 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased]  - T-1675: Project-scoped milestone-name lookups now preserve the exact SwiftData fetch failure through shared assignment as `INTERNAL_ERROR` (`Failed to look up milestone: <error>`), matching JSON/MCP create, update, and scoped-query adapters. The milestone service's injected fetcher is now available in MCP test setup; deterministic regressions prove no-match and ambiguity remain distinct, cross-project unscoped filtering is untouched, and failed lookups create or mutate nothing.+- T-1825: Dashboard milestone filters now live-observe local, MCP, and CloudKit milestone changes, retaining selected Done or Abandoned rows within the active project scope so they remain visible and individually deselectable. Open rows remain first and follow their displayed title order; terminal selections are appended deterministically and deduplicated by UUID. Selected rows expose the native selected accessibility trait once, while inaccessible or deleted selections still leave Clear available. Add Task remains open-only.  - T-1657: Project lookup storage failures now remain distinguishable from missing projects across JSON intents, MCP create/query/milestone tools, and visual Add Task. `QueryMilestonesIntent` no longer returns a successful empty array when its project-name lookup is unreadable, while valid missing/ambiguous names and project-ID precedence retain their prior behavior. Visual Add Task now reports `INTERNAL_ERROR` for a failed project read rather than a stale-selection `PROJECT_NOT_FOUND`; deterministic failing-fetch regressions verify cross-surface parity and no task or milestone insertion. - T-1800: Open task details now observe a task-scoped SwiftData comment query instead of a one-time snapshot, so MCP and local insertions/deletions update both the visible comment list and Share export without reopening the view. The query filters through the CloudKit-compatible optional child relationship and uses creation date plus UUID for stable ordering.
Transit/Transit/Views/Dashboard/MilestoneFilterMenu.swift Modified +
diff --git a/Transit/Transit/Views/Dashboard/MilestoneFilterMenu.swift b/Transit/Transit/Views/Dashboard/MilestoneFilterMenu.swiftindex 188e6be..bddabd0 100644--- a/Transit/Transit/Views/Dashboard/MilestoneFilterMenu.swift+++ b/Transit/Transit/Views/Dashboard/MilestoneFilterMenu.swift@@ -1,3 +1,4 @@+import SwiftData import SwiftUI  struct MilestoneFilterMenu: View {@@ -5,32 +6,54 @@ struct MilestoneFilterMenu: View {     let selectedProjectIDs: Set<UUID>     @Binding var selectedMilestones: Set<UUID> -    @Environment(MilestoneService.self) private var milestoneService+    // Load-bearing observation: do not replace this with a service snapshot.+    // It refreshes an already-presented menu after local, MCP, or CloudKit changes.+    // Project scoping stays in memory because the dynamic selected-project set cannot+    // be expressed by a CloudKit-safe SwiftData predicate while retaining terminal rows.+    @Query private var allMilestones: [Milestone]     @Environment(\.horizontalSizeClass) private var sizeClass      @State private var showPopover = false -    private var availableMilestones: [Milestone] {+    private var milestoneOptions: [Milestone] {         Self.availableMilestones(+            milestones: allMilestones,             projects: projects,             selectedProjectIDs: selectedProjectIDs,-            milestoneService: milestoneService+            selectedMilestones: selectedMilestones+        )+    }++    private var hasVisibleMilestoneOption: Bool {+        Self.hasVisibleMilestoneOption(+            milestones: allMilestones,+            projects: projects,+            selectedProjectIDs: selectedProjectIDs         )     }      var body: some View {         if Self.shouldShowMenu(-            availableMilestones: availableMilestones,-            selectedMilestones: selectedMilestones+            hasVisibleMilestoneOption: hasVisibleMilestoneOption,+            selectedMilestones: selectedMilestones,+            isPresented: showPopover         ) {             Button { showPopover.toggle() } label: { filterLabel }                 .accessibilityIdentifier("dashboard.filter.milestones")                 .accessibilityLabel(Self.accessibilityLabel(for: selectedMilestones.count))+                .onChange(of: hasVisibleMilestoneOption) {+                    if showPopover && Self.shouldDismissPresentation(+                        hasVisibleMilestoneOption: hasVisibleMilestoneOption,+                        selectedMilestones: selectedMilestones+                    ) {+                        showPopover = false+                    }+                }                 #if os(macOS)                 .popover(isPresented: $showPopover) {                     List {                         Section {-                            toggleContent+                            toggleContent(milestoneOptions)                         }                         clearSection                     }@@ -40,7 +63,7 @@ struct MilestoneFilterMenu: View {                 .sheet(isPresented: $showPopover) {                     NavigationStack {                         List {-                            toggleContent+                            toggleContent(milestoneOptions)                             clearSection                         }                         .navigationTitle("Milestones")@@ -59,14 +82,29 @@ struct MilestoneFilterMenu: View {     }      @ViewBuilder-    private var toggleContent: some View {-        ForEach(availableMilestones) { milestone in+    private func toggleContent(_ milestones: [Milestone]) -> some View {+        ForEach(milestones) { milestone in             Button {                 $selectedMilestones.contains(milestone.id).wrappedValue.toggle()+                if Self.shouldDismissPresentation(+                    hasVisibleMilestoneOption: hasVisibleMilestoneOption,+                    selectedMilestones: selectedMilestones+                ) {+                    showPopover = false+                }             } label: {                 HStack {-                    Text(milestoneTitle(for: milestone))-                        .foregroundStyle(.primary)+                    Text(Self.milestoneTitle(for: milestone, selectedProjectIDs: selectedProjectIDs))+                        .strikethrough(milestone.status.isTerminal)+                        .foregroundStyle(milestone.status.isTerminal ? .secondary : .primary)+                    if milestone.status.isTerminal {+                        Label(+                            milestone.status.displayName,+                            systemImage: milestone.status == .done ? "checkmark.circle" : "xmark.circle"+                        )+                        .font(.caption)+                        .foregroundStyle(.secondary)+                    }                     Spacer()                     if selectedMilestones.contains(milestone.id) {                         Image(systemName: "checkmark")@@ -76,6 +114,11 @@ struct MilestoneFilterMenu: View {                 .contentShape(Rectangle())             }             .buttonStyle(.plain)+            .accessibilityLabel(Self.milestoneAccessibilityLabel(+                for: milestone,+                selectedProjectIDs: selectedProjectIDs+            ))+            .accessibilityAddTraits(selectedMilestones.contains(milestone.id) ? .isSelected : [])         }     } @@ -85,15 +128,30 @@ struct MilestoneFilterMenu: View {             Section {                 Button("Clear", role: .destructive) {                     selectedMilestones.removeAll()+                    if Self.shouldDismissPresentation(+                        hasVisibleMilestoneOption: hasVisibleMilestoneOption,+                        selectedMilestones: selectedMilestones+                    ) {+                        showPopover = false+                    }                 }             }         }     } -    private func milestoneTitle(for milestone: Milestone) -> String {+    static func milestoneTitle(for milestone: Milestone, selectedProjectIDs: Set<UUID>) -> String {         selectedProjectIDs.count == 1 ? milestone.name : milestone.displayName     } +    static func milestoneAccessibilityLabel(+        for milestone: Milestone,+        selectedProjectIDs: Set<UUID>+    ) -> String {+        let title = milestoneTitle(for: milestone, selectedProjectIDs: selectedProjectIDs)+        guard milestone.status.isTerminal else { return title }+        return "\(title), \(milestone.status.displayName)"+    }+     @ViewBuilder     private var filterLabel: some View {         let count = selectedMilestones.count@@ -108,17 +166,97 @@ struct MilestoneFilterMenu: View {         }     } -    static func shouldShowMenu(availableMilestones: [Milestone], selectedMilestones: Set<UUID>) -> Bool {-        !availableMilestones.isEmpty || !selectedMilestones.isEmpty+    static func shouldShowMenu(+        hasVisibleMilestoneOption: Bool,+        selectedMilestones: Set<UUID>,+        isPresented: Bool = false+    ) -> Bool {+        isPresented || hasVisibleMilestoneOption || !selectedMilestones.isEmpty+    }++    static func shouldDismissPresentation(+        hasVisibleMilestoneOption: Bool,+        selectedMilestones: Set<UUID>+    ) -> Bool {+        !hasVisibleMilestoneOption && selectedMilestones.isEmpty+    }++    static func hasVisibleMilestoneOption(+        milestones: [Milestone],+        projects: [Project],+        selectedProjectIDs: Set<UUID>+    ) -> Bool {+        let scopedProjectIDs = scopedProjectIDs(+            projects: projects,+            selectedProjectIDs: selectedProjectIDs+        )+        return milestones.contains { milestone in+            isAccessible(milestone, in: scopedProjectIDs) && milestone.status == .open+        }     }      static func availableMilestones(+        milestones: [Milestone],         projects: [Project],         selectedProjectIDs: Set<UUID>,-        milestoneService: MilestoneService+        selectedMilestones: Set<UUID>     ) -> [Milestone] {-        let scopedProjects = scopedProjects(projects: projects, selectedProjectIDs: selectedProjectIDs)-        return scopedProjects.flatMap { milestoneService.milestonesForProject($0, status: .open) }+        let scopedProjectIDs = scopedProjectIDs(+            projects: projects,+            selectedProjectIDs: selectedProjectIDs+        )+        let accessibleMilestones = orderedMilestones(+            milestones.filter { isAccessible($0, in: scopedProjectIDs) },+            selectedProjectIDs: selectedProjectIDs+        )+        let visibleIDs = visibleMilestoneIDs(+            openMilestoneIDs: accessibleMilestones.filter { $0.status == .open }.map(\.id),+            selectedAccessibleMilestoneIDs: accessibleMilestones.filter {+                selectedMilestones.contains($0.id)+            }.map(\.id)+        )+        let milestonesByID = Dictionary(+            accessibleMilestones.map { ($0.id, $0) },+            uniquingKeysWith: { first, _ in first }+        )+        return visibleIDs.compactMap { milestonesByID[$0] }+    }++    private static func scopedProjectIDs(+        projects: [Project],+        selectedProjectIDs: Set<UUID>+    ) -> Set<UUID> {+        Set(scopedProjects(+            projects: projects,+            selectedProjectIDs: selectedProjectIDs+        ).map(\.id))+    }++    private static func isAccessible(_ milestone: Milestone, in projectIDs: Set<UUID>) -> Bool {+        guard let projectID = milestone.project?.id else { return false }+        return projectIDs.contains(projectID)+    }++    private static func orderedMilestones(+        _ milestones: [Milestone],+        selectedProjectIDs: Set<UUID>+    ) -> [Milestone] {+        let titledMilestones = milestones.map {+            (title: milestoneTitle(for: $0, selectedProjectIDs: selectedProjectIDs), milestone: $0)+        }+        return titledMilestones.sorted { lhs, rhs in+            let titleOrder = lhs.title.localizedCaseInsensitiveCompare(rhs.title)+            if titleOrder != .orderedSame { return titleOrder == .orderedAscending }+            return lhs.milestone.id.uuidString < rhs.milestone.id.uuidString+        }.map(\.milestone)+    }++    nonisolated static func visibleMilestoneIDs(+        openMilestoneIDs: [UUID],+        selectedAccessibleMilestoneIDs: [UUID]+    ) -> [UUID] {+        var seen = Set<UUID>()+        return (openMilestoneIDs + selectedAccessibleMilestoneIDs).filter { seen.insert($0).inserted }     }      static func scopedProjects(projects: [Project], selectedProjectIDs: Set<UUID>) -> [Project] {
Transit/TransitTests/MilestoneFilterMenuTests.swift Modified +
diff --git a/Transit/TransitTests/MilestoneFilterMenuTests.swift b/Transit/TransitTests/MilestoneFilterMenuTests.swiftindex 09b4588..c7ac935 100644--- a/Transit/TransitTests/MilestoneFilterMenuTests.swift+++ b/Transit/TransitTests/MilestoneFilterMenuTests.swift@@ -19,32 +19,196 @@ struct MilestoneFilterMenuTests {     }      @Test func menuHiddenWhenNoAvailableAndNoneSelected() {-        #expect(MilestoneFilterMenu.shouldShowMenu(availableMilestones: [], selectedMilestones: []) == false)+        #expect(MilestoneFilterMenu.shouldShowMenu(+            hasVisibleMilestoneOption: false,+            selectedMilestones: []+        ) == false)     } -    @Test func availableMilestonesScopedToSelectedProjects() throws {+    @Test func menuRemainsMountedWhilePresentedAfterClearingLastSelection() {+        #expect(MilestoneFilterMenu.shouldShowMenu(+            hasVisibleMilestoneOption: false,+            selectedMilestones: [],+            isPresented: true+        ))+        #expect(MilestoneFilterMenu.shouldDismissPresentation(+            hasVisibleMilestoneOption: false,+            selectedMilestones: []+        ))+        #expect(!MilestoneFilterMenu.shouldDismissPresentation(+            hasVisibleMilestoneOption: true,+            selectedMilestones: []+        ))+        #expect(!MilestoneFilterMenu.shouldDismissPresentation(+            hasVisibleMilestoneOption: false,+            selectedMilestones: [UUID()]+        ))+    }+    @Test func visibleMilestoneOptionUsesOpenRecordsWithinCurrentProjectScope() {+        let firstProject = Project(name: "First", description: "", gitRepo: nil, colorHex: "#FF0000")+        let secondProject = Project(name: "Second", description: "", gitRepo: nil, colorHex: "#00FF00")+        let firstOpen = Milestone(name: "Open", project: firstProject, displayID: .provisional)+        let secondTerminal = Milestone(name: "Done", project: secondProject, displayID: .provisional)+        secondTerminal.status = .done++        #expect(MilestoneFilterMenu.hasVisibleMilestoneOption(+            milestones: [firstOpen, secondTerminal],+            projects: [firstProject, secondProject],+            selectedProjectIDs: [firstProject.id]+        ))+        #expect(!MilestoneFilterMenu.hasVisibleMilestoneOption(+            milestones: [firstOpen, secondTerminal],+            projects: [firstProject, secondProject],+            selectedProjectIDs: [secondProject.id]+        ))+    }++    @Test func visibleMilestoneIDsPreservesOpenOrderAndDeduplicatesSelectedMilestones() {+        let firstOpenID = UUID()+        let secondOpenID = UUID()+        let doneID = UUID()+        let abandonedID = UUID()++        let visibleIDs = MilestoneFilterMenu.visibleMilestoneIDs(+            openMilestoneIDs: [firstOpenID, secondOpenID, firstOpenID],+            selectedAccessibleMilestoneIDs: [doneID, secondOpenID, abandonedID, doneID]+        )++        #expect(visibleIDs == [firstOpenID, secondOpenID, doneID, abandonedID])+    }++    @Test func availableMilestonesHandlesPersistedStatusTransitionWithDeterministicTerminalPlacement() throws {         let testContainer = try TestModelContainer()         let context = testContainer.context-        let allocator = DisplayIDAllocator(store: InMemoryCounterStore())-        let milestoneService = MilestoneService(modelContext: context, displayIDAllocator: allocator)-         let firstProject = Project(name: "First", description: "", gitRepo: nil, colorHex: "#FF0000")         let secondProject = Project(name: "Second", description: "", gitRepo: nil, colorHex: "#00FF00")         context.insert(firstProject)         context.insert(secondProject) -        let firstMilestone = Milestone(name: "M1", project: firstProject, displayID: .provisional)-        let secondMilestone = Milestone(name: "M2", project: secondProject, displayID: .provisional)-        context.insert(firstMilestone)-        context.insert(secondMilestone)+        let alphaOpen = Milestone(name: "Alpha", project: firstProject, displayID: .provisional)+        let betaTransition = Milestone(name: "Beta", project: firstProject, displayID: .provisional)+        let gammaTerminal = Milestone(name: "Gamma", project: firstProject, displayID: .provisional)+        gammaTerminal.status = .abandoned+        let hiddenTerminal = Milestone(name: "Hidden", project: firstProject, displayID: .provisional)+        hiddenTerminal.status = .done+        let zuluOpen = Milestone(name: "Zulu", project: firstProject, displayID: .provisional)+        let inaccessibleTerminal = Milestone(name: "Other", project: secondProject, displayID: .provisional)+        inaccessibleTerminal.status = .done+        [+            zuluOpen,+            gammaTerminal,+            hiddenTerminal,+            alphaOpen,+            inaccessibleTerminal,+            betaTransition+        ].forEach(context.insert)         try context.save() -        let available = MilestoneFilterMenu.availableMilestones(+        let selectedMilestones: Set<UUID> = [betaTransition.id, gammaTerminal.id, inaccessibleTerminal.id]+        let initiallyAvailable = MilestoneFilterMenu.availableMilestones(+            milestones: try context.fetch(FetchDescriptor<Milestone>()),+            projects: [firstProject, secondProject],+            selectedProjectIDs: [firstProject.id],+            selectedMilestones: selectedMilestones+        )+        let initialAvailableIDs: [UUID] = initiallyAvailable.map(\.id)+        let initialExpectedIDs: [UUID] = [+            alphaOpen.id, betaTransition.id, zuluOpen.id, gammaTerminal.id+        ]+        #expect(initialAvailableIDs == initialExpectedIDs)++        betaTransition.status = .done+        try context.save()++        let transitionedAvailable = MilestoneFilterMenu.availableMilestones(+            milestones: try context.fetch(FetchDescriptor<Milestone>()),             projects: [firstProject, secondProject],             selectedProjectIDs: [firstProject.id],-            milestoneService: milestoneService+            selectedMilestones: selectedMilestones+        )+        let transitionedAvailableIDs: [UUID] = transitionedAvailable.map(\.id)+        let transitionedExpectedIDs: [UUID] = [+            alphaOpen.id, zuluOpen.id, betaTransition.id, gammaTerminal.id+        ]+        #expect(transitionedAvailableIDs == transitionedExpectedIDs)+    }++    @Test func multiProjectOrderingMatchesDisplayedMilestoneTitles() {+        let alphaProject = Project(name: "Alpha", description: "", gitRepo: nil, colorHex: "#FF0000")+        let betaProject = Project(name: "Beta", description: "", gitRepo: nil, colorHex: "#00FF00")+        let alphaMilestone = Milestone(name: "Beta", project: alphaProject, displayID: .provisional)+        let betaMilestone = Milestone(name: "Alpha", project: betaProject, displayID: .provisional)+        let betaTerminal = Milestone(name: "Release", project: betaProject, displayID: .provisional)+        betaTerminal.status = .done++        let available = MilestoneFilterMenu.availableMilestones(+            milestones: [betaTerminal, betaMilestone, alphaMilestone],+            projects: [betaProject, alphaProject],+            selectedProjectIDs: [],+            selectedMilestones: [betaTerminal.id]+        )+        let titles = available.map {+            MilestoneFilterMenu.milestoneTitle(for: $0, selectedProjectIDs: [])+        }++        #expect(titles == ["Alpha - Beta", "Beta - Alpha", "Beta - Release"])+    }++    @Test func deletedSelectedMilestoneLeavesMenuAvailableForClear() throws {+        let testContainer = try TestModelContainer()+        let context = testContainer.context+        let project = Project(name: "Project", description: "", gitRepo: nil, colorHex: "#FF0000")+        let otherProject = Project(name: "Other", description: "", gitRepo: nil, colorHex: "#00FF00")+        let milestone = Milestone(name: "Deleted", project: project, displayID: .provisional)+        let otherOpenMilestone = Milestone(name: "Other Open", project: otherProject, displayID: .provisional)+        context.insert(project)+        context.insert(otherProject)+        context.insert(milestone)+        context.insert(otherOpenMilestone)+        try context.save()++        context.delete(milestone)+        try context.save()+        let observedMilestones = try context.fetch(FetchDescriptor<Milestone>())+        let available = MilestoneFilterMenu.availableMilestones(+            milestones: observedMilestones,+            projects: [project, otherProject],+            selectedProjectIDs: [project.id],+            selectedMilestones: [milestone.id]+        )+        let hasVisibleOption = MilestoneFilterMenu.hasVisibleMilestoneOption(+            milestones: observedMilestones,+            projects: [project, otherProject],+            selectedProjectIDs: [project.id]         ) -        #expect(Set(available.map(\.id)) == [firstMilestone.id])+        #expect(available.isEmpty)+        #expect(!hasVisibleOption)+        #expect(MilestoneFilterMenu.shouldShowMenu(+            hasVisibleMilestoneOption: hasVisibleOption,+            selectedMilestones: [milestone.id]+        ))+    }++    @Test func accessibilityLabelsIncludeTerminalStatus() {+        let project = Project(name: "Project", description: "", gitRepo: nil, colorHex: "#FF0000")+        let openMilestone = Milestone(name: "Open", project: project, displayID: .provisional)+        let doneMilestone = Milestone(name: "Closed", project: project, displayID: .provisional)+        doneMilestone.status = .done+        let abandonedMilestone = Milestone(name: "Retired", project: project, displayID: .provisional)+        abandonedMilestone.status = .abandoned++        #expect(MilestoneFilterMenu.milestoneAccessibilityLabel(+            for: openMilestone,+            selectedProjectIDs: [project.id]+        ) == "Open")+        #expect(MilestoneFilterMenu.milestoneAccessibilityLabel(+            for: doneMilestone,+            selectedProjectIDs: [project.id]+        ) == "Closed, Done")+        #expect(MilestoneFilterMenu.milestoneAccessibilityLabel(+            for: abandonedMilestone,+            selectedProjectIDs: [project.id]+        ) == "Retired, Abandoned")     } }
specs/bugfixes/terminal-milestone-filter-selection/report.md Modified +
diff --git a/specs/bugfixes/terminal-milestone-filter-selection/report.md b/specs/bugfixes/terminal-milestone-filter-selection/report.mdnew file mode 100644index 0000000..32db985--- /dev/null+++ b/specs/bugfixes/terminal-milestone-filter-selection/report.md@@ -0,0 +1,87 @@+# Bugfix Report: Terminal milestone selections disappear from filter menu++**Date:** 2026-08-05+**Status:** Fixed++## Description of the Issue++A selected dashboard milestone disappeared from `MilestoneFilterMenu` after it transitioned to Done or Abandoned. Its UUID remained in the active filter, so its tasks continued to filter the dashboard, but the user could neither see the selected milestone nor deselect it individually.++**Reproduction steps:**+1. Select an open milestone from the dashboard filter.+2. Change that milestone to Done or Abandoned through Settings, MCP, or a synced device.+3. Reopen the dashboard milestone filter.++**Impact:** The filter count reported a selection that no longer had a visible menu row. Users could only clear every milestone filter at once.++## Investigation Summary++- **Symptoms examined:** A non-zero filter badge persisted while the corresponding terminal milestone row vanished.+- **Code inspected:** `MilestoneFilterMenu`, `MilestoneService.milestonesForProject`, dashboard filter tests, UI-test scenario support, and the milestone design specification.+- **Hypotheses tested:** Confirmed the problem was local to the dashboard menu: it requested `.open` milestones only. Add Task independently remains open-only by design and was not changed.++## Discovered Root Cause++`MilestoneFilterMenu.availableMilestones` populated its rows solely from open milestones. `selectedMilestones` is UUID state that is intentionally retained across a milestone status transition, but the menu made no union with selected accessible milestones. Its service-backed read also did not make the presented SwiftUI menu observe externally persisted status or deletion changes.++**Defect type:** Logic error / stale selection state++**Why it occurred:** The menu applied the normal open-only choice rule to existing selections, even though terminal selections must remain available for removal.++**Contributing factors:** The selection-list construction had no pure ordering/deduplication helper or transition regression, and it did not observe the SwiftData records that external MCP and CloudKit updates mutate.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/Views/Dashboard/MilestoneFilterMenu.swift` - Observes all milestones with a SwiftData `@Query`, so local status/deletion mutations, MCP changes, and CloudKit imports refresh an already-open menu. It scopes the observed records in memory because the dynamic selected-project set cannot be represented in a CloudKit-safe SwiftData predicate, then lists open rows before selected terminal rows once by UUID. Within one scoped project rows sort by milestone name; across projects they sort by displayed project/name title, with UUID as a stable final tie-breaker. Duplicate IDs retain one row rather than trapping. Terminal rows remain struck through and display their status. Selected rows use the native `.isSelected` accessibility trait, while each row label contains only its title and optional terminal status, preventing duplicate selected announcements.+- `Transit/TransitTests/MilestoneFilterMenuTests.swift` - Adds pure UUID-union coverage, a persisted Done transition regression proving open-first/terminal-second deterministic placement, displayed multi-project ordering coverage, a deletion/Clear regression, and exact open/Done/Abandoned label coverage.++**Approach rationale:** The dashboard now exposes only the normal open choices plus selected records the user can still act on. The live query avoids stale menu content while stable in-memory ordering matches the title users see. The native selection trait is shared by open and terminal selected rows and is announced once. Add Task and task-edit creation behavior remain open-only.++**Alternatives considered:**+- Show every terminal milestone in the dashboard filter - rejected because it unnecessarily expands normal options and conflicts with the open-only default.+- Clear terminal selections automatically - rejected because it silently changes an active dashboard filter and prevents deliberate deselection.++## Regression Test++**Test file:** `Transit/TransitTests/MilestoneFilterMenuTests.swift`+**Test names:** `visibleMilestoneOptionUsesOpenRecordsWithinCurrentProjectScope`, `visibleMilestoneIDsPreservesOpenOrderAndDeduplicatesSelectedMilestones`, `availableMilestonesHandlesPersistedStatusTransitionWithDeterministicTerminalPlacement`, `multiProjectOrderingMatchesDisplayedMilestoneTitles`, `deletedSelectedMilestoneLeavesMenuAvailableForClear`, `menuRemainsMountedWhilePresentedAfterClearingLastSelection`, `accessibilityLabelsIncludeTerminalStatus`++**What it verifies:** The production visibility predicate treats only scoped open records as normal choices; a fresh SwiftData snapshot sees an open selected milestone move into the selected-terminal suffix after a persisted Done transition while an in-scope unselected terminal stays absent; open rows stay first in deterministic order; multi-project ordering matches displayed titles; another project is excluded; deleting a selected record preserves access to Clear; and a presentation dismisses when it would otherwise become empty after local or observed external changes. Labels contain one title plus optional terminal status.++**Run command:** `make test-quick`++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/Views/Dashboard/MilestoneFilterMenu.swift` | Observe all milestones; scope, title-order, and union open/selected-terminal records; expose terminal status and native selected accessibility state. |+| `Transit/TransitTests/MilestoneFilterMenuTests.swift` | Add pure union, persisted transition, displayed-order, deletion/Clear, and accessibility regressions. |+| `CHANGELOG.md` | Record the user-visible dashboard filter fix. |++## Verification++**Automated:**+- [x] Full macOS unit suite passes (`make test-quick`).+- [x] SwiftLint passes (`make lint`).+- [x] iOS Simulator and macOS builds pass (`make build`).+- [x] Focused iOS simulator regression suite passes (`-only-testing:TransitTests/MilestoneFilterMenuTests`).+- [ ] Full `make test` exceeded the five-minute command harness after compiling both test bundles and beginning iOS execution; it did not return a final result.+- [ ] Full `make test-ui` exceeded the five-minute command harness after completing UI execution. It reported failures in `TransitUITests.testClearAll`, `TransitUITests.testEditViewPreservesTaskMilestone`, and `DataMaintenanceUITests.testDataMaintenanceGoldenPath`; the unchanged `TransitUITests.testMilestoneFilterMenu` passed. A baseline run on `origin/main` was not completed in this session.++**Manual verification:**+- Confirmed the persisted transition regression excludes an in-scope unselected terminal, keeps multi-project title ordering stable, preserves deleted-selection Clear access, dismisses an otherwise empty presentation, and covers the accessibility label contract.+- A new UI transition assertion was not retained: XCTest cannot reliably query a SwiftUI list row's updated accessibility state while the sheet remains presented, and a second sheet round trip destabilizes the seeded board assertion. The existing milestone filter UI flow passes, while focused unit coverage verifies the data and accessibility contracts directly.++## Prevention++- Keep selection-list construction in pure helpers that explicitly union normal options and current accessible selections.+- Use an observed SwiftData source for menu options that must react to external mutations.+- Keep deterministic menu ordering aligned with the title users see.+- Add state-transition coverage whenever an active selection may become invalid for new choices.+- Keep creation flows such as Add Task separate from edit/filter flows that must preserve existing terminal state.++## Related++- Transit ticket `T-1825`+- `specs/milestones/design.md` section 5.1
docs/agent-notes/stream2-ui-views.md Modified +
diff --git a/docs/agent-notes/stream2-ui-views.md b/docs/agent-notes/stream2-ui-views.mdindex 823f481..55af43b 100644--- a/docs/agent-notes/stream2-ui-views.md+++ b/docs/agent-notes/stream2-ui-views.md@@ -117,12 +117,11 @@ Applied to: AddTaskSheet (macOS description), TaskEditView (macOS description), - Persistence flow lives in `AddTaskSheet.persist(draft:taskService:milestoneService:)` (a static helper) so the view's save logic is exercisable from unit tests. The view's `save()` builds a `TaskDraft` and delegates to `persist`. - Orphan cleanup: if `setMilestone` throws after `createTask` succeeded, `persist` deletes the newly-created task via `taskService.deleteTask(task)` before rethrowing. Mirrors the cleanup pattern in `CreateTaskIntent` / MCP `create_task` (T-558, T-855). Keep all three entry points in sync when changing this contract. -### FilterPopoverView Milestones Section-- New `selectedMilestones: Set<UUID>` binding (state owned by DashboardView)-- Milestones section after Types section, same toggle pattern-- Scoped by project filter (all open milestones if no project filter active)-- Stale selected milestones (no longer open) shown dimmed for deselection-- Clears milestone selection when project filter changes+### MilestoneFilterMenu+- `DashboardView` owns the ephemeral `selectedMilestones: Set<UUID>` state and passes it into `MilestoneFilterMenu`.+- `MilestoneFilterMenu` observes all milestones with `@Query`; it scopes the dynamic project selection in memory, keeps open rows first, and appends selected Done/Abandoned rows so they remain deselectable.+- Rows sort by the exact displayed milestone title with a UUID tie-breaker. Terminal rows are dimmed/struck through and show their status; selected rows carry the native selected accessibility trait.+- Changing the project filter clears milestone selection.  ### DashboardView Milestone Filter - In-memory filter: `selectedMilestones` passed to `DashboardLogic.buildFilteredColumns`

Things to double-check

Full-suite environment

make test and make test-ui exceed the five-minute command harness. The UI target reported three failures; this branch does not modify those tests and no origin/main baseline was run in this session. See the committed bugfix report for exact details.