diff --git a/iosApp/iosApp/Task/TaskViewModel.swift b/iosApp/iosApp/Task/TaskViewModel.swift index d163104..0bc0850 100644 --- a/iosApp/iosApp/Task/TaskViewModel.swift +++ b/iosApp/iosApp/Task/TaskViewModel.swift @@ -42,6 +42,12 @@ class TaskViewModel: ObservableObject { private let dataManager: DataManagerObservable // MARK: - Initialization + /// Single source of truth = DataManager._allTasks. When this VM is + /// residence-scoped (currentResidenceId set), filter in-memory by + /// residence id. Eliminates the gitea#2 race window where the + /// per-residence cache slot could be empty while _allTasks was + /// populated. The per-residence cache is gone (cec521b). + /// /// - Parameter dataManager: Observable cache the VM subscribes to. /// Defaults to the shared singleton. Tests inject a fixture-backed /// instance so populated-state snapshots render real data. @@ -50,35 +56,26 @@ class TaskViewModel: ObservableObject { // Seed from current cache so snapshot tests/previews render // populated state without waiting for Combine's async dispatch. + // The seed path mirrors the steady-state filter below — if this + // VM is residence-scoped at construction time the seed has to + // pre-filter too, but currentResidenceId is set after init via + // setResidenceFilter(...), so seeding the unfiltered list is fine. self.tasksResponse = dataManager.allTasks - // Observe injected DataManagerObservable for all tasks data + // Observe injected DataManagerObservable for all tasks data. dataManager.$allTasks .receive(on: DispatchQueue.main) .sink { [weak self] allTasks in - // Skip DataManager updates during completion animation to prevent - // the task from being moved out of its column before the animation finishes - guard self?.isAnimatingCompletion != true else { return } - // Only update if we're showing all tasks (no residence filter) - if self?.currentResidenceId == nil { - self?.tasksResponse = allTasks - if allTasks != nil { - self?.isLoadingTasks = false - } - } - } - .store(in: &cancellables) + guard let self else { return } + guard !self.isAnimatingCompletion else { return } - // Observe tasks by residence - dataManager.$tasksByResidence - .receive(on: DispatchQueue.main) - .sink { [weak self] tasksByResidence in - guard self?.isAnimatingCompletion != true else { return } - // Only update if we're filtering by residence - if let resId = self?.currentResidenceId, - let tasks = tasksByResidence[resId] { - self?.tasksResponse = tasks - self?.isLoadingTasks = false + if let allTasks { + if let resId = self.currentResidenceId { + self.tasksResponse = self.filterTasks(allTasks, residenceId: resId) + } else { + self.tasksResponse = allTasks + } + self.isLoadingTasks = false } } .store(in: &cancellables) @@ -392,6 +389,28 @@ class TaskViewModel: ObservableObject { } } + /// Filter the all-tasks kanban down to a single residence in-memory. + /// Mirrors `DataManager.getTasksForResidence` on the Kotlin side. + private func filterTasks(_ response: TaskColumnsResponse, residenceId: Int32) -> TaskColumnsResponse { + let filteredColumns = response.columns.map { column -> TaskColumn in + let filteredTasks = column.tasks.filter { Int32($0.residenceId) == residenceId } + return TaskColumn( + name: column.name, + displayName: column.displayName, + buttonTypes: column.buttonTypes, + icons: column.icons, + color: column.color, + tasks: filteredTasks, + count: Int32(filteredTasks.count) + ) + } + return TaskColumnsResponse( + columns: filteredColumns, + daysThreshold: response.daysThreshold, + residenceId: String(residenceId) + ) + } + /// Updates a task in the kanban board by moving it to the correct column based on kanban_column func updateTaskInKanban(_ updatedTask: TaskResponse) { guard let currentResponse = tasksResponse else { return }