feat: bundle ID migration + gitea#2 task-cache fix (recovered from fix/task-cache-unification) #4

Merged
admin merged 13 commits from feat/bundle-id-and-task-cache into master 2026-05-01 20:48:29 -05:00
Showing only changes of commit 882801c71d - Show all commits
+42 -23
View File
@@ -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 }