From 65803a21806ff8e69242d95b79a155239b9421ec Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 25 Apr 2026 09:25:04 -0500 Subject: [PATCH] plan: task cache unification (closes gitea#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the bug where tasks created during onboarding don't appear on the Residence Detail screen until app restart. Root cause: DataManager.updateTask is a no-op when both _allTasks is null AND _tasksByResidence[residenceId] is empty — the case after a fresh register-then-bulk-create flow. Approach: collapse the dual cache into a single source of truth (_allTasks). Residence detail observes it directly and filters by residenceId in-memory. After mutations, force-refresh _allTasks from the server (one round-trip eliminates a class of bugs). Plan covers 14 tasks across 4 phases plus a regression XCUITest that captures the user-visible bug end-to-end. --- .../2026-04-25-task-cache-unification.md | 919 ++++++++++++++++++ 1 file changed, 919 insertions(+) create mode 100644 docs/plans/2026-04-25-task-cache-unification.md diff --git a/docs/plans/2026-04-25-task-cache-unification.md b/docs/plans/2026-04-25-task-cache-unification.md new file mode 100644 index 0000000..2d15035 --- /dev/null +++ b/docs/plans/2026-04-25-task-cache-unification.md @@ -0,0 +1,919 @@ +# Task Cache Unification Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make `_allTasks` the single source of truth for tasks; collapse `_tasksByResidence` into a derived view. Fix the bug where tasks created during onboarding don't appear on the Residence Detail screen until app restart (Gitea issue #2). + +**Architecture:** The current code has two parallel task caches that must be kept in sync (`_allTasks` for the kanban tab, `_tasksByResidence` per-residence for the residence detail screen). `DataManager.updateTask` is a no-op when either cache is empty, so post-`bulkCreateTasks` the new tasks live only on the server until something forces a fetch. After this change there is exactly one cache (`_allTasks`); residence detail screens observe it and apply an in-memory filter by `residenceId`. Mutations (`createTask`, `bulkCreateTasks`) force a refresh of `_allTasks` from the server to guarantee freshness with one round-trip instead of relying on conditional branches that silently skip. + +**Tech Stack:** Kotlin Multiplatform (commonMain), Ktor client, kotlinx.serialization, Combine bridge to SwiftUI iOS, Compose StateFlow on Android. Test framework: `kotlin.test` in commonTest. + +**Affected files:** +- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt` — remove `_tasksByResidence`, simplify `updateTask`/`removeTask`, add upsert behavior +- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt` — change `bulkCreateTasks`, `createTask`, `getTasksByResidence` +- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt` — feed `_residenceTasksState` from a `combine(allTasks, residenceId)` flow +- `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt` (new) — cache behavior tests +- `iosApp/iosApp/Task/TaskViewModel.swift` — drop `$tasksByResidence` sink, filter `$allTasks` when residence-scoped +- `iosApp/iosApp/Data/DataManagerObservable.swift` — drop `tasksByResidence` `@Published` and its `for await` task + +**Out of scope (do not touch):** +- Backend Go API — `/api/tasks/by-residence/:id/` endpoint stays in place untouched (might still be used by web admin) +- Android `ResidenceDetailScreen.kt` — the screen contract (`residenceViewModel.residenceTasksState`) is preserved; only the VM internals change +- Disk persistence schema — kotlinx.serialization is configured with `ignoreUnknownKeys` for forward/backward compat (verified in Task 11) + +--- + +## Pre-flight + +### Task 0: Verify clean state and baseline + +**Files:** none (read only) + +**Step 1: Confirm working tree is clean (or only the expected exception)** + +Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP status --short` +Expected: empty, **or** the only line is `M composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt`. That single file is intentionally on `Environment.LOCAL` for the duration of this work — it stays uncommitted and gets flipped back to `Environment.PROD` in Task 11 Step 5. If anything else shows up, stop and ask the user. + +**Step 2: Confirm we're on a feature branch (not master)** + +Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP rev-parse --abbrev-ref HEAD` +Expected: NOT `master`. If `master`, run `git checkout -b fix/task-cache-unification` before continuing. + +**Step 3: Run the existing commonTest baseline so we know what currently passes** + +Run: `./gradlew :composeApp:testDebugUnitTest` +Expected: BUILD SUCCESSFUL. Note the count — every later run must keep ≥ this count of green tests. + +**Step 4: Build iOS to confirm starting point compiles** + +Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20` +Expected: `** BUILD SUCCEEDED **` + +No commit — this is verification only. + +### Task 0.5: Failing regression XCUITest — reproduce the bug end-to-end + +**Goal:** Write a UI test that drives the exact onboarding-to-residence-detail flow from gitea#2 and asserts that tasks appear on the residence detail screen without an app restart. Run it now — it MUST fail. The Phase 1-3 fixes will make it pass; Task 12 re-runs it as the final gate. + +**Why before the unit tests:** The unit tests in Phase 1 catch the bug at the cache layer, but the *user-facing* bug is "I tap my residence and see 'no tasks'". A passing UI test is the only thing that proves the user experience is actually fixed. Writing it once up front + running it once at the end is cheaper than running a full UI cycle every iteration. + +**Files:** +- Create: `iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift` +- Maybe modify: `iosApp/HoneyDueUITests/AccessibilityIdentifiers.swift` (only if missing IDs are needed — see Step 2) +- Maybe modify: SwiftUI views in `iosApp/iosApp/` (only if the view layer is missing accessibility identifiers — see Step 2) + +**Pre-requisites already satisfied by Task 0 setup:** +- iOS app is on `Environment.LOCAL` +- Docker stack is up and healthy at `http://127.0.0.1:8000` +- `DEBUG=true` on the local API → email confirmation code is fixed at `123456` (saves a manual step in the test) + +**Step 1: Pick a clean run by wiping prior simulator state** + +Run: `xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev` +Run: `docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down && docker volume rm honeydueapi-go_postgres_data && docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d` +Wait until: `curl -fsS http://127.0.0.1:8000/api/health/` returns 200. + +**Step 2: Audit accessibility identifiers along the test path** + +The test taps and asserts on these SwiftUI surfaces. Open `iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift` and verify (or add) identifiers for each. Use stable, non-localized strings. + +Surfaces to identify: +| Where | Why the test needs it | Suggested ID constant | +|---|---|---| +| Login/Register screen — username, email, password, first/last name fields, "Register" button, "Verify" code field | Drive registration | already in `AccessibilityIdentifiers.Authentication.*` per existing UI tests — verify by grep | +| Onboarding — residence-creation form (name field + Continue) | Drive residence creation | `AccessibilityIdentifiers.Onboarding.residenceNameField`, `.continueButton` (add if missing) | +| Onboarding First-Task screen — "Browse All" tab button | Switch from suggestions to browse | `AccessibilityIdentifiers.Onboarding.browseAllTab` (add if missing) | +| Onboarding First-Task screen — each template row (selectable) | Pick 3 tasks | `AccessibilityIdentifiers.Onboarding.templateRowPrefix` (e.g., `"onboarding.template."`) — see how `OnboardingFirstTaskView.swift` renders rows; add an `.accessibilityIdentifier(...)` keyed on `template.id` | +| Onboarding First-Task screen — Submit button | Trigger bulk-create | `AccessibilityIdentifiers.Onboarding.submitTasksButton` (add if missing) | +| Residence list / home — the residence cell | Tap into detail | `AccessibilityIdentifiers.Residence.cellPrefix` (e.g., `"residence.cell."` or ``) — verify in `ResidenceListView` or wherever the post-onboarding landing screen renders cells | +| Residence detail — task row | Assert presence | `AccessibilityIdentifiers.Task.rowPrefix` (e.g., `"task.row"`) — verify the task list inside `TasksSectionContainer` in `ResidenceDetailView.swift:538` | +| Residence detail — empty state ("No tasks" copy) | Assert ABSENCE | `AccessibilityIdentifiers.Task.noTasksLabel` (add if missing) — find the empty-state copy in the residence-detail tasks section and pin an identifier on it | + +For each missing ID, add it in two places: +1. The constant in `AccessibilityIdentifiers.swift` +2. `.accessibilityIdentifier(AccessibilityIdentifiers.X.Y)` on the SwiftUI view + +Keep these app-side additions in **a single dedicated commit** so reviewers can see "test scaffolding only, no behavior change": + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add -A +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: add accessibility identifiers along the onboarding-to-residence-detail path + +Scaffolding for the gitea#2 regression XCUITest. No user-visible +change — pure metadata for UI automation." +``` + +**Step 3: Write the failing UI test** + +Create `iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift`: + +```swift +import XCTest + +/// Regression test for gitea#2. +/// +/// Onboarding flow: register → create residence → pick 3 tasks → submit. +/// After submit, the user lands on the home/residences screen. They tap +/// the new residence WITHOUT visiting the Tasks tab first (the Tasks tab +/// triggers a `getTasks()` that masks the bug by populating `_allTasks`). +/// +/// Expected: residence detail shows ≥1 task row within 10s. +/// Pre-fix: residence detail shows empty state ("no tasks") forever +/// until the app is restarted. +final class Suite11_TaskCacheRegressionTests: XCTestCase { + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart() throws { + let app = XCUIApplication() + app.launchArguments += ["UI-Testing"] + app.launch() + + // 1. Register a fresh user. Email confirmation code is fixed at "123456" + // in DEBUG mode (DEBUG_FIXED_CODES=true on the local docker stack). + let stamp = String(Int(Date().timeIntervalSince1970)) + UITestHelpers.register( + in: app, + username: "uitest\(stamp)", + email: "uitest+\(stamp)@treymail.com", + password: "UItest\(stamp)!aZ", + confirmationCode: "123456" + ) + + // 2. Onboarding: create the residence. + UITestHelpers.completeResidenceCreation(in: app, name: "UI Test Property") + + // 3. Switch to "Browse All" tab and pick 3 templates. The "For You" + // suggestions tab depends on a server-side recommendation that + // might be empty for a freshly created residence; Browse is + // deterministic. + let browseTab = app.buttons[AccessibilityIdentifiers.Onboarding.browseAllTab] + XCTAssertTrue(browseTab.waitForExistence(timeout: 5), + "Browse All tab must appear on First-Task screen") + browseTab.tap() + + let templates = app.buttons.matching( + NSPredicate(format: "identifier BEGINSWITH %@", + AccessibilityIdentifiers.Onboarding.templateRowPrefix) + ) + + // Wait for the catalog to load — fresh API call against local backend. + XCTAssertTrue(templates.element(boundBy: 0).waitForExistence(timeout: 10), + "Template catalog must load") + + for i in 0..<3 { + templates.element(boundBy: i).tap() + } + + // 4. Submit. This calls APILayer.bulkCreateTasks → POST /api/tasks/bulk/ + // The bug lives in the cache update path between this call returning + // and the residence detail screen rendering. + let submit = app.buttons[AccessibilityIdentifiers.Onboarding.submitTasksButton] + XCTAssertTrue(submit.waitForExistence(timeout: 3)) + submit.tap() + + // 5. Land on home/residences. Tap the residence we just created. + // Critical: do NOT visit the Tasks tab — that would call getTasks() + // and populate _allTasks via setAllTasks, masking the bug. + let residenceCell = app.buttons[ + AccessibilityIdentifiers.Residence.cellPrefix + "UI Test Property" + ] + XCTAssertTrue(residenceCell.waitForExistence(timeout: 10), + "Residence cell must appear on home after onboarding submit") + residenceCell.tap() + + // 6. Residence detail must show ≥1 task row, NOT the empty state. + // Generous timeout (10s) covers the network round-trip on slow + // local Docker startups. + let firstTaskRow = app.cells + .matching(NSPredicate(format: "identifier BEGINSWITH %@", + AccessibilityIdentifiers.Task.rowPrefix)) + .firstMatch + XCTAssertTrue( + firstTaskRow.waitForExistence(timeout: 10), + "Tasks created during onboarding must appear on residence detail without restart (gitea#2)" + ) + + let emptyState = app.staticTexts[AccessibilityIdentifiers.Task.noTasksLabel] + XCTAssertFalse( + emptyState.exists, + "Empty 'no tasks' state must NOT show when tasks exist (gitea#2)" + ) + + // 7. Cleanup — delete the test user via UI (or skip; clearing the + // docker volume between runs is the cheaper reset). + } +} +``` + +**Notes for the engineer writing this test:** + +- `UITestHelpers.register(...)` and `UITestHelpers.completeResidenceCreation(...)` may not exist verbatim — read `iosApp/HoneyDueUITests/UITestHelpers.swift` for the existing helpers. If `register(...)` exists but doesn't take a `confirmationCode:` arg, either add one or inline the verification step. +- DO NOT use `sleep()` anywhere. Use `waitForExistence(timeout:)` everywhere. The skill `axiom-ui-testing` is loaded if you need patterns. +- `continueAfterFailure = false` so we stop at the exact assertion that fails — easier to triage video. +- If you can't get a residence cell identifier reliably (e.g., the home screen shows a custom layout, not standard cells), substitute `app.staticTexts["UI Test Property"]` and tap that. The point is to land on the residence detail without going through the Tasks tab. + +**Step 4: Run the test — must FAIL** + +Run: +``` +xcodebuild -project iosApp/honeyDue.xcodeproj \ + -scheme HoneyDueUITests \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \ + test 2>&1 | tail -40 +``` + +Expected: `Test Suite '...' failed.` with the assertion **"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"**. + +If it FAILS for a different reason (residence cell not found, timeout on browse tab, etc.) → that's an accessibility-identifier mismatch, not a bug repro. Fix the test/IDs and re-run. The test must fail SPECIFICALLY on the "no tasks on residence detail" assertion to be a valid bug capture. + +If it PASSES → the bug isn't reproducing in this environment. Possibilities: +- App was already cached with `_allTasks` from a prior run (re-run Step 1 to fully wipe simulator + DB) +- The user navigated through the Tasks tab implicitly (check the home screen layout) +- The bug only happens on a code path you didn't replicate (re-read the iOS-side onboarding flow) + +**Step 5: Commit the failing test** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: failing — onboarding tasks must appear on residence detail without restart + +Captures gitea#2 at the user-visible level. The kanban tab works but +the residence detail screen does not, until the app is restarted. This +test must FAIL at this commit and PASS after the cache unification work. +Re-run gates the merge in Task 12." +``` + +The test stays failing through Phase 1-3 commits. Don't run it on every commit — it's slow. Run it once at the end (Task 12). + +--- + +## Phase 1 — TDD: catch the bug, then fix it + +### Task 1: Failing test — `bulkCreateTasks` must populate `_allTasks` + +**Files:** +- Create: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt` + +**Step 1: Write the failing test** + +This test reproduces the onboarding bug at the cache layer. We can't easily mock Ktor here without infrastructure, so we test the cache mutation contract directly: after a successful bulk-create, `_allTasks` MUST contain every returned task, regardless of prior cache state. + +```kotlin +package com.tt.honeyDue.data + +import com.tt.honeyDue.models.TaskResponse +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.BeforeTest + +class DataManagerTaskCacheTest { + + @BeforeTest + fun resetState() { + DataManager.clearAllData() + } + + @Test + fun `updateTask seeds _allTasks when cache is empty`() { + // Given: fresh DataManager with no tasks loaded (the onboarding scenario) + assertEquals(null, DataManager.allTasks.value) + + // When: a new task arrives via the same path bulkCreateTasks uses + val task = TaskResponse( + id = 1, + residenceId = 100, + title = "Replace HVAC filter", + kanbanColumn = "upcoming_tasks", + // ... fill remaining required TaskResponse fields with sensible defaults + ) + DataManager.updateTask(task) + + // Then: _allTasks is populated with this task in the right column + val allTasks = DataManager.allTasks.value + assertNotNull(allTasks, "updateTask must seed _allTasks even when it was null") + + val upcomingColumn = allTasks.columns.firstOrNull { it.name == "upcoming_tasks" } + assertNotNull(upcomingColumn) + assertTrue( + upcomingColumn.tasks.any { it.id == 1 }, + "task must land in the upcoming_tasks column" + ) + } +} +``` + +You'll need to look at `TaskResponse` in `composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/CustomTask.kt` to fill the required fields. Use defaults that match an onboarding-created task (no completion, no priority, due-soon date). + +**Step 2: Run the test — must FAIL** + +Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask seeds _allTasks when cache is empty"` +Expected: FAIL with `expected: but was:` (or similar). This proves the test catches the bug. + +**Step 3: Commit the failing test** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: failing — DataManager.updateTask must seed _allTasks" +``` + +### Task 2: Make `DataManager.updateTask` a real upsert + +**Files:** +- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt:482-520` + +**Step 1: Replace the conditional branch on `_allTasks` with an upsert** + +Current (lines 484-498): +```kotlin +_allTasks.value?.let { current -> + val targetColumn = task.kanbanColumn ?: "upcoming_tasks" + val newColumns = current.columns.map { column -> ... } + _allTasks.value = current.copy(columns = newColumns) +} +``` + +Replace with: +```kotlin +val targetColumn = task.kanbanColumn ?: "upcoming_tasks" +val current = _allTasks.value ?: TaskColumnsResponse( + columns = standardKanbanColumns(), // see Step 2 + daysThreshold = 30, + residenceId = null +) +val newColumns = current.columns.map { column -> + val filteredTasks = column.tasks.filter { it.id != task.id } + val updatedTasks = if (column.name == targetColumn) filteredTasks + task else filteredTasks + column.copy(tasks = updatedTasks, count = updatedTasks.size) +} +// If targetColumn doesn't exist in current.columns (e.g. fresh seed), append it +val finalColumns = if (newColumns.none { it.name == targetColumn }) { + newColumns + Column(name = targetColumn, tasks = listOf(task), count = 1, /* fill rest */) +} else newColumns +_allTasks.value = current.copy(columns = finalColumns) +``` + +**Step 2: Add `standardKanbanColumns()` helper** + +Look at the backend response — `internal/repositories/task_repo.go` `GetKanbanDataForMultipleResidences` defines the column order. Mirror it in Kotlin: + +```kotlin +private fun standardKanbanColumns(): List = listOf( + Column(name = "overdue_tasks", tasks = emptyList(), count = 0, /* defaults */), + Column(name = "due_soon_tasks", tasks = emptyList(), count = 0, /* defaults */), + Column(name = "in_progress_tasks", tasks = emptyList(), count = 0, /* defaults */), + Column(name = "upcoming_tasks", tasks = emptyList(), count = 0, /* defaults */), + Column(name = "completed_tasks", tasks = emptyList(), count = 0, /* defaults */), + // archived/cancelled are hidden from kanban — see honeyDueAPI-go/CLAUDE.md +) +``` + +Look at `Column` in `CustomTask.kt` for its required fields (display label, color, etc.). Fill in matching defaults. + +**Step 3: Drop the second branch (`_tasksByResidence`) — it's going away in Phase 3** + +Remove lines 500-515 entirely. The `_tasksByResidence` slot is still there for now (Phase 3 deletes it), but `updateTask` should not write to it any more. + +**Step 4: Run the test — must PASS** + +Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask seeds _allTasks when cache is empty"` +Expected: PASS + +**Step 5: Run the full test suite to confirm no regressions** + +Run: `./gradlew :composeApp:testDebugUnitTest` +Expected: same green count as Task 0 baseline + 1 new test. + +**Step 6: Commit** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "fix: DataManager.updateTask seeds _allTasks when cache is empty + +Closes the silent no-op when _allTasks is null on first launch (the +onboarding bulkCreateTasks path). The function now upserts: builds an +empty kanban shell if needed and places the task in its target column. +Adds an unknown column at the end for forward compatibility with future +column names from the server. + +Refs gitea#2" +``` + +### Task 3: Add upsert test for `_tasksByResidence` deletion guard + +**Files:** +- Modify: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt` + +**Step 1: Add a test asserting `updateTask` does NOT touch `_tasksByResidence` any more** + +```kotlin +@Test +fun `updateTask no longer mutates _tasksByResidence`() { + val before = DataManager.tasksByResidence.value + DataManager.updateTask(/* sample task */) + assertEquals(before, DataManager.tasksByResidence.value, + "updateTask must not touch _tasksByResidence — it's deprecated") +} +``` + +**Step 2: Run — must PASS** (we already removed the branch in Task 2) + +Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask no longer mutates _tasksByResidence"` +Expected: PASS + +**Step 3: Commit** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: lock down that updateTask no longer writes _tasksByResidence" +``` + +--- + +## Phase 2 — Belt-and-suspenders: force-refresh after mutations + +The Phase 1 upsert handles the fresh-cache case correctly, but it makes assumptions about kanban column placement based on the response's `kanbanColumn` field. The server is the authoritative kanban categorizer. To eliminate any drift, also force a `_allTasks` refresh after multi-task mutations. + +### Task 4: Force `_allTasks` refresh after `bulkCreateTasks` + +**Files:** +- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt:647-655` + +**Step 1: Add post-success refresh** + +Current: +```kotlin +suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult { + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) + val result = taskApi.bulkCreateTasks(token, request) + + if (result is ApiResult.Success) { + DataManager.setTotalSummary(result.data.summary) + result.data.tasks.forEach { DataManager.updateTask(it) } + } + return result +} +``` + +New: +```kotlin +suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult { + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) + val result = taskApi.bulkCreateTasks(token, request) + + if (result is ApiResult.Success) { + DataManager.setTotalSummary(result.data.summary) + // Authoritative refresh — server knows the right kanban placement. + // Cheap (one round-trip) and eliminates any client-side drift between + // the per-task kanbanColumn hint and the global kanban view. + getTasks(forceRefresh = true) + } + return result +} +``` + +Drop the `forEach { updateTask }` — it becomes redundant with the force-refresh. + +**Step 2: Run the full test suite** + +Run: `./gradlew :composeApp:testDebugUnitTest` +Expected: all green (the Phase 1 upsert tests still pass because they exercise `updateTask` directly, not `bulkCreateTasks`). + +**Step 3: Commit** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "fix: bulkCreateTasks force-refreshes _allTasks instead of merging task-by-task + +Server is the authoritative kanban categorizer. After a bulk insert, +re-fetch /api/tasks/ so the kanban view reflects exactly what the +server sees, including any column re-categorizations the client's +in-memory upsert wouldn't compute. One extra round-trip per onboarding +submission, called once per session typically. + +Refs gitea#2" +``` + +--- + +## Phase 3 — Collapse the dual cache + +### Task 5: Characterization test for `getTasksForResidence` + +`getTasksForResidence` already implements the filter we want to use everywhere. Lock it down with a test before we make it the primary path. + +**Files:** +- Modify: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt` + +**Step 1: Add the test** + +```kotlin +@Test +fun `getTasksForResidence filters _allTasks by residence id`() { + DataManager.setAllTasks(/* response with tasks across residences 100 and 200 */) + + val r100 = DataManager.getTasksForResidence(100) + assertNotNull(r100) + assertTrue(r100.columns.flatMap { it.tasks }.all { it.residenceId == 100 }) + + val r999 = DataManager.getTasksForResidence(999) + assertNotNull(r999) + assertEquals(0, r999.columns.sumOf { it.tasks.size }) // valid id, just no tasks +} + +@Test +fun `getTasksForResidence returns null when _allTasks is null`() { + DataManager.clearAllData() + assertEquals(null, DataManager.getTasksForResidence(100)) +} +``` + +**Step 2: Run — must PASS** (no implementation change yet) + +Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.getTasksForResidence*"` +Expected: PASS for both. + +**Step 3: Commit** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: characterize getTasksForResidence filter contract" +``` + +### Task 6: Simplify `APILayer.getTasksByResidence` + +**Files:** +- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt:591-621` + +**Step 1: Replace the 3-path implementation with "ensure _allTasks fresh, then filter"** + +```kotlin +suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult { + // Ensure _allTasks is loaded and reasonably fresh. + // getTasks itself respects forceRefresh and the global tasksCacheTime. + val allTasksResult = getTasks(forceRefresh = forceRefresh) + if (allTasksResult is ApiResult.Error) return allTasksResult + + val filtered = DataManager.getTasksForResidence(residenceId) + ?: return ApiResult.Error("Tasks unavailable", 0) + return ApiResult.Success(filtered) +} +``` + +This deletes the per-residence cache reliance entirely. `_tasksByResidence` is no longer written by this path. + +**Step 2: Run the test suite** + +Run: `./gradlew :composeApp:testDebugUnitTest` +Expected: all green. + +**Step 3: Commit** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "refactor: getTasksByResidence is now a thin filter over _allTasks + +Was 3 fallback paths (per-residence cache → filter from allTasks → +network). Now: ensure _allTasks fresh, return filter. The per-residence +cache becomes write-only by this path, scheduled for deletion in the +next commit." +``` + +### Task 7: iOS — `TaskViewModel` observes `$allTasks` with filter + +**Files:** +- Modify: `iosApp/iosApp/Task/TaskViewModel.swift:46-74` + +**Step 1: Replace dual-sink with single-sink + filter** + +Current logic uses two Combine sinks: `$allTasks` (only when `currentResidenceId == nil`) and `$tasksByResidence` (only when set). + +Replace with one sink on `$allTasks` that conditionally filters: + +```swift +DataManagerObservable.shared.$allTasks + .receive(on: DispatchQueue.main) + .sink { [weak self] allTasks in + guard let self else { return } + guard !self.isAnimatingCompletion else { return } + + if let allTasks { + if let resId = self.currentResidenceId { + self.tasksResponse = self.filterByResidence(allTasks, residenceId: resId) + } else { + self.tasksResponse = allTasks + } + self.isLoadingTasks = false + } + } + .store(in: &cancellables) +``` + +**Step 2: Add the `filterByResidence` helper** + +```swift +private func filterByResidence(_ response: TaskColumnsResponse, residenceId: Int32) -> TaskColumnsResponse { + let filteredColumns = response.columns.map { column -> Column in + let filteredTasks = column.tasks.filter { Int32($0.residenceId ?? 0) == residenceId } + return column.copy(tasks: filteredTasks, count: Int32(filteredTasks.count)) + } + return response.copy(columns: filteredColumns, residenceId: String(residenceId)) +} +``` + +(Use `.doCopy(...)` SKIE syntax if `.copy` isn't directly callable from Swift — check what other Swift code does with TaskColumnsResponse copies.) + +**Step 3: Drop the `$tasksByResidence` subscription block entirely** + +Remove the second `.sink` on `$tasksByResidence` (currently lines 62-74). + +**Step 4: Build iOS** + +Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20` +Expected: `** BUILD SUCCEEDED **` + +**Step 5: Run iOS unit tests** + +Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:HoneyDueTests test 2>&1 | tail -20` +Expected: TEST SUCCEEDED. + +**Step 6: Commit** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add iosApp/iosApp/Task/TaskViewModel.swift +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "ios: TaskViewModel observes \$allTasks and filters by residence in-memory + +Single source of truth eliminates the race window where the residence +detail screen could mount before the per-residence cache slot existed. +After this, every emit of _allTasks rerenders every observing view — +kanban tab, residence detail, dashboards — atomically. + +Refs gitea#2" +``` + +### Task 8: Android — `ResidenceViewModel` feeds `residenceTasksState` from a combined flow + +**Files:** +- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt:31-94` + +**Step 1: Replace the imperative `loadResidenceTasks` with a derived flow** + +Look at how `_residenceTasksState` is currently populated (line 88-94). Instead of imperatively calling `APILayer.getTasksByResidence` and storing the result, derive it: + +```kotlin +private val _currentResidenceId = MutableStateFlow(null) + +val residenceTasksState: StateFlow> = + combine(DataManager.allTasks, _currentResidenceId) { all, id -> + when { + id == null -> ApiResult.Idle + all == null -> ApiResult.Loading + else -> { + val filtered = DataManager.getTasksForResidence(id) + if (filtered != null) ApiResult.Success(filtered) else ApiResult.Loading + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ApiResult.Idle) + +fun loadResidenceTasks(residenceId: Int, forceRefresh: Boolean = false) { + viewModelScope.launch { + _currentResidenceId.value = residenceId + // Trigger the underlying _allTasks refresh; the combine above + // re-emits Success when allTasks arrives. + APILayer.getTasks(forceRefresh = forceRefresh) + } +} +``` + +The screen contract (`residenceViewModel.residenceTasksState`) is preserved — `ResidenceDetailScreen.kt:59` doesn't need any change. + +**Step 2: Build Android debug** + +Run: `./gradlew :composeApp:assembleDebug` +Expected: BUILD SUCCESSFUL. + +**Step 3: Run commonTest again** + +Run: `./gradlew :composeApp:testDebugUnitTest` +Expected: all green. (`ResidenceViewModelTest` may need adjusting — check it.) + +**Step 4: Commit** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt +# Also stage any test fixes +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "android: ResidenceViewModel.residenceTasksState derives from _allTasks + +Same screen contract, but the data flows from DataManager.allTasks +through a combine(...) into the existing StateFlow. No per-residence +network call needed; the upstream getTasks() refresh propagates." +``` + +### Task 9: Delete dead code — `_tasksByResidence` and friends + +**Files:** +- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt` +- Modify: `iosApp/iosApp/Data/DataManagerObservable.swift` + +**Step 1: In DataManager.kt, delete:** + +- `_tasksByResidence` (line 141) and `tasksByResidence` (line 142) +- `tasksByResidenceCacheTime` (line 65) +- `setTasksForResidence` (lines 448-451) +- `invalidateTasksFor` (line 417 — verify it has no other callers first via grep) +- `_tasksByResidence` mutations in `removeTask` (lines 533-onwards) — keep only the `_allTasks` removal +- `_tasksByResidence.value = emptyMap()` in clearAllData and similar wipes (lines 783, 836) +- `tasksByResidenceCacheTime.clear()` in same wipes (lines 814, 849) + +Keep `getTasksForResidence` — it's the public filter API, still used by the new `getTasksByResidence` and Android VM. + +**Step 2: In DataManagerObservable.swift, delete:** + +- `@Published var tasksByResidence: [Int32: TaskColumnsResponse] = [:]` (line 44) +- The `for await tasks in DataManager.shared.tasksByResidence` task (lines 195-201) +- The `tasksByResidence[residenceId]` reader at line 524 (replace with `DataManager.shared.getTasksForResidence(residenceId)` or its iOS-friendly equivalent if anything still calls this — grep first) + +**Step 3: Compile both targets** + +Run: `./gradlew :composeApp:assembleDebug && ./gradlew :composeApp:testDebugUnitTest` +Then: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20` +Expected: both BUILD SUCCEEDED, all tests green. + +If anything fails to compile, follow the compiler — there's likely a missed reader. Common suspects: `TaskViewModel.kt` (Kotlin VM, not Swift) line ~38-42 references `_tasksByResidenceState`; verify it's still wired correctly or also delete it. + +**Step 4: Commit** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add -A +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "refactor: delete _tasksByResidence and per-residence task cache plumbing + +All readers and writers gone after the previous commits. Single source +of truth = DataManager._allTasks, residence views derive via +getTasksForResidence(id). Net deletion ~100 LOC across DataManager, +APILayer, DataManagerObservable, and iOS TaskViewModel. + +Closes gitea#2" +``` + +--- + +## Phase 4 — Verification + +### Task 10: Verify disk persistence is forward-compatible + +**Files:** none (verification only) + +**Step 1: Find the persistence model** + +Run: `grep -rn "ignoreUnknownKeys\|Json {\|tasksByResidence" composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/PersistenceManager.kt` +Expected: kotlinx.serialization Json config with `ignoreUnknownKeys = true`. If NOT, an existing user upgrading the app will crash on first launch when the persisted blob has the now-removed `tasksByResidence` field. + +**Step 2: If `ignoreUnknownKeys` is missing, ADD IT BEFORE SHIPPING** + +Edit the Json config: +```kotlin +val json = Json { + ignoreUnknownKeys = true + // ... existing config +} +``` +Commit separately as `chore: persistence Json must ignoreUnknownKeys`. + +**Step 3: Manual test — wipe simulator app data, install old app, then this version** + +If you have a TestFlight build of the previous version: +1. Install old version → register → create residence → quit +2. Update to this build → launch → confirm no crash, data loads +3. Quit → relaunch → confirm persistence works correctly + +If no old TestFlight build available, skip this empirical check but the `ignoreUnknownKeys` setting is sufficient. + +### Task 11: Manual smoke — the actual bug repro + +**Files:** none (manual test) + +**Step 1: Wipe simulator state for the dev build** + +Run: `xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev` + +**Step 2: Confirm iOS is on LOCAL (set during pre-flight, stays uncommitted)** + +Run: `grep "CURRENT_ENV" /Users/treyt/Desktop/code/honeyDue/honeyDueKMP/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt` +Expected: `val CURRENT_ENV = Environment.LOCAL`. If it's not, edit the file. DO NOT COMMIT — revert in Step 5. + +**Step 3: Build and install** + +Run: `./gradlew :composeApp:assembleDebug` and Xcode build to simulator. + +**Step 4: Reproduce the original bug path** + +1. Launch app → land on register screen +2. Register a fresh user with a unique email +3. Onboarding: create residence → choose 3+ tasks from the catalog → submit +4. Land on home/dashboard +5. Navigate to the new residence's detail screen WITHOUT visiting the Tasks tab first +6. **Expected: tasks visible immediately. No "no tasks" state. No restart needed.** + +If the bug still reproduces, return to Phase 1 — the upsert or refresh isn't working. Capture iOS console with `xclog launch com.myhoneydue.honeyDue.dev` and inspect. + +**Step 5: Revert the ApiConfig change** + +Edit `ApiConfig.kt` back to `Environment.PROD` (or whatever it was). Confirm with `git diff`. Do not commit. + +### Task 12: Final regression sweep + +**Files:** none (verification only) + +**Step 1: Full Kotlin test suite green** + +Run: `./gradlew :composeApp:testDebugUnitTest` + +**Step 2: iOS unit tests green** + +Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme HoneyDue -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:HoneyDueTests test 2>&1 | tail -20` + +**Step 3: The Task 0.5 regression XCUITest now passes** + +Wipe state for a clean run (mirrors Task 0.5 Step 1): +``` +xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev +docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down +docker volume rm honeydueapi-go_postgres_data +docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d +``` + +Wait for `curl -fsS http://127.0.0.1:8000/api/health/` → 200. Then re-run the regression test: + +``` +xcodebuild -project iosApp/honeyDue.xcodeproj \ + -scheme HoneyDueUITests \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \ + test 2>&1 | tail -20 +``` + +Expected: **TEST SUCCEEDED**. The "Tasks created during onboarding must appear on residence detail without restart" assertion now holds. + +**If it FAILS:** the cache fix is incomplete. Inspect the test report video (`xcrun xcresulttool get --path build/reports/...xcresult ...`) and follow the failure point. Common causes: missed updateTask call site in the dual-cache deletion, a residual reader of `_tasksByResidence` in iOS not pruned, or a race between `getTasks(forceRefresh=true)` and the residence detail's first observation. **DO NOT** weaken the test to make it pass — fix the underlying issue. + +**Step 4: Stress run (catch flakiness before merge)** + +Run the test 5× to confirm it's stable, not just lucky: +``` +for i in 1 2 3 4 5; do + xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev + docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down >/dev/null + docker volume rm honeydueapi-go_postgres_data >/dev/null + docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d >/dev/null + until curl -fsS http://127.0.0.1:8000/api/health/ >/dev/null 2>&1; do sleep 2; done + xcodebuild -project iosApp/honeyDue.xcodeproj -scheme HoneyDueUITests -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \ + test 2>&1 | tail -3 + echo "=== run $i done ===" +done +``` + +Expected: 5/5 TEST SUCCEEDED. If even one fails, treat as flaky — don't merge. + +**Step 5: Diff summary** + +Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP diff --stat master...HEAD` +Expected: net deletion ~80-150 lines across the listed files. If the diff is much larger, scope creep — review commits. + +**Step 6: Push and open a PR (only if user confirms)** + +Don't push without asking the user. Wait for explicit go-ahead. + +--- + +## Rollback plan + +If anything goes sideways in production: +1. `git revert ` — every commit in this plan is independently revertable in reverse order, but the cleanest rollback is reverting the merge commit. +2. Old persistence blob format is preserved by `ignoreUnknownKeys` — no migration required. +3. Backend `/api/tasks/by-residence/:id/` was never touched, so a rolled-back client immediately starts using it again with no server change. + +--- + +## Notes for the executing engineer + +- **Frequent commits.** Every task ends with a commit. If you deviate from the plan, commit before deviating. +- **Don't auto-commit any other changes.** Per `honeyDueKMP/CLAUDE.md`: "DO NOT auto-commit code changes." Commit only what's specified. +- **Don't push to remote.** Let the user trigger the push after they review. +- **TaskColumnsResponse fields.** The `Column` data class in `CustomTask.kt` may have more fields than shown (display label, color, sort order). Read it before writing the standard column shell in Task 2 — the test will fail on missing required constructor args. +- **TaskResponse fields.** Same — has many fields. For test fixtures, build a small helper: + ```kotlin + private fun sampleTask(id: Int, residenceId: Int, column: String) = TaskResponse(...) + ``` + in the test file rather than repeating the giant constructor. +- **SKIE/Swift copy.** TaskColumnsResponse `.copy()` from Swift may need `.doCopy(...)` if SKIE renamed it. Check `iosApp/iosApp/Task/TaskViewModel.swift` line 387 onward for an existing example of how Swift copies a Kotlin data class. +- **Don't refactor "while you're here."** This plan is laser-focused on the cache unification. Other smells you spot — log them, don't fix them in this PR.