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.
This commit is contained in:
Trey t
2026-04-25 09:25:04 -05:00
parent ef8eab4a07
commit 65803a2180
@@ -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.<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":
```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:<not null> but was:<null>` (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<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**
```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<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:
```kotlin
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**
```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<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**
```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<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**
```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 <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:
```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.