Files
honeyDueKMP/docs/plans/2026-04-25-task-cache-unification.md
Trey t 65803a2180 plan: task cache unification (closes gitea#2)
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-05-01 18:30:58 -07:00

43 KiB
Raw Permalink Blame History

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.<id>") — 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.<name>" or <id>) — 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":

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:

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

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.

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:<not null> but was:<null> (or similar). This proves the test catches the bug.

Step 3: Commit the failing test

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):

_allTasks.value?.let { current ->
    val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
    val newColumns = current.columns.map { column -> ... }
    _allTasks.value = current.copy(columns = newColumns)
}

Replace with:

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:

private fun standardKanbanColumns(): List<Column> = 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

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

@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

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:

suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
    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:

suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
    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

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

@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

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"

suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
    // 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

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:

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

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

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:

private val _currentResidenceId = MutableStateFlow<Int?>(null)

val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> =
    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

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

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:

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 <merge-commit-sha> — 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:
    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.