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.
43 KiB
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, simplifyupdateTask/removeTask, add upsert behaviorcomposeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt— changebulkCreateTasks,createTask,getTasksByResidencecomposeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt— feed_residenceTasksStatefrom acombine(allTasks, residenceId)flowcomposeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt(new) — cache behavior testsiosApp/iosApp/Task/TaskViewModel.swift— drop$tasksByResidencesink, filter$allTaskswhen residence-scopediosApp/iosApp/Data/DataManagerObservable.swift— droptasksByResidence@Publishedand itsfor awaittask
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
ignoreUnknownKeysfor 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=trueon the local API → email confirmation code is fixed at123456(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:
- The constant in
AccessibilityIdentifiers.swift .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(...)andUITestHelpers.completeResidenceCreation(...)may not exist verbatim — readiosApp/HoneyDueUITests/UITestHelpers.swiftfor the existing helpers. Ifregister(...)exists but doesn't take aconfirmationCode:arg, either add one or inline the verification step.- DO NOT use
sleep()anywhere. UsewaitForExistence(timeout:)everywhere. The skillaxiom-ui-testingis loaded if you need patterns. continueAfterFailure = falseso 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
_allTasksfrom 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) andtasksByResidence(line 142)tasksByResidenceCacheTime(line 65)setTasksForResidence(lines 448-451)invalidateTasksFor(line 417 — verify it has no other callers first via grep)_tasksByResidencemutations inremoveTask(lines 533-onwards) — keep only the_allTasksremoval_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.tasksByResidencetask (lines 195-201) - The
tasksByResidence[residenceId]reader at line 524 (replace withDataManager.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:
- Install old version → register → create residence → quit
- Update to this build → launch → confirm no crash, data loads
- 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
- Launch app → land on register screen
- Register a fresh user with a unique email
- Onboarding: create residence → choose 3+ tasks from the catalog → submit
- Land on home/dashboard
- Navigate to the new residence's detail screen WITHOUT visiting the Tasks tab first
- 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:
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.- Old persistence blob format is preserved by
ignoreUnknownKeys— no migration required. - 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
Columndata class inCustomTask.ktmay 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:
in the test file rather than repeating the giant constructor.
private fun sampleTask(id: Int, residenceId: Int, column: String) = TaskResponse(...) - SKIE/Swift copy. TaskColumnsResponse
.copy()from Swift may need.doCopy(...)if SKIE renamed it. CheckiosApp/iosApp/Task/TaskViewModel.swiftline 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.