diff --git a/.claude/settings.local.json b/.claude/settings.local.json index cf7dbae..61a2dda 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -18,7 +18,8 @@ "Bash(ps:*)", "Bash(stdbuf:*)", "Bash(sysctl:*)", - "Bash(tee:*)" + "Bash(tee:*)", + "Bash(codesign -d --entitlements :- /Users/treyt/Library/Developer/Xcode/DerivedData/honeyDue-buvczbpttcfkxxcmxbnqkqrmujyh/Build/Products/Debug-iphonesimulator/honeyDue.app)" ] } } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt index 988028b..fe42461 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt @@ -504,45 +504,60 @@ object DataManager : IDataManager { * Also refreshes the summary from the updated kanban data. */ fun updateTask(task: TaskResponse) { - // Update in allTasks - _allTasks.value?.let { current -> - val targetColumn = task.kanbanColumn ?: "upcoming_tasks" - val newColumns = current.columns.map { column -> - // Remove task from this column if present - val filteredTasks = column.tasks.filter { it.id != task.id } - // Add task if this is the target column - val updatedTasks = if (column.name == targetColumn) { - filteredTasks + task - } else { - filteredTasks - } - column.copy(tasks = updatedTasks, count = updatedTasks.size) - } - _allTasks.value = current.copy(columns = newColumns) - } + val targetColumn = task.kanbanColumn ?: "upcoming_tasks" - // Update in tasksByResidence if this task's residence is cached - task.residenceId?.let { residenceId -> - _tasksByResidence.value[residenceId]?.let { current -> - val targetColumn = task.kanbanColumn ?: "upcoming_tasks" - 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) - } - _tasksByResidence.value = _tasksByResidence.value + (residenceId to current.copy(columns = newColumns)) - } + // Upsert into _allTasks. Crucially, when _allTasks is null (fresh + // launch, kanban never fetched — the gitea#2 bug scenario), seed + // an empty kanban shell so the new task isn't silently dropped. + // The Phase 2 force-refresh after bulkCreateTasks/createTask will + // replace this shell with authoritative server data shortly. + val current = _allTasks.value ?: emptyKanbanShell() + val columnsWithTarget = if (current.columns.any { it.name == targetColumn }) { + current.columns + } else { + // Server returned a kanban_column the client doesn't know about + // yet — append it so the task is still reachable. + current.columns + emptyColumn(targetColumn) } + val newColumns = columnsWithTarget.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) + } + _allTasks.value = current.copy(columns = newColumns) // Refresh summary from updated kanban data (API no longer returns summaries for CRUD) refreshSummaryFromKanban() persistToDisk() } + /// Default kanban skeleton used when `_allTasks` was never populated. + /// Display metadata is intentionally placeholder — the Phase 2 force-refresh + /// in `APILayer.bulkCreateTasks` / `createTask` replaces these shortly with + /// authoritative server values. The `name` field is the contract — every + /// observer keys off it. + private fun emptyKanbanShell(): TaskColumnsResponse = TaskColumnsResponse( + columns = listOf( + emptyColumn("overdue_tasks"), + emptyColumn("due_soon_tasks"), + emptyColumn("in_progress_tasks"), + emptyColumn("upcoming_tasks"), + emptyColumn("completed_tasks") + ), + daysThreshold = 30, + residenceId = "" + ) + + private fun emptyColumn(name: String): TaskColumn = TaskColumn( + name = name, + displayName = "", + buttonTypes = emptyList(), + icons = emptyMap(), + color = "", + tasks = emptyList(), + count = 0 + ) + fun removeTask(taskId: Int) { // Remove from allTasks _allTasks.value?.let { current -> diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt index b1a20da..552c6b3 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt @@ -615,36 +615,22 @@ object APILayer { return result } + /** + * Returns kanban data for a single residence. Single source of truth + * is `_allTasks`; this function ensures it's fresh, then filters. + * + * Replaces the previous 3-path implementation (per-residence cache → + * filter from allTasks → API) that produced inconsistent results + * when the per-residence cache slot was empty but `_allTasks` was + * stale. Phase 3 deletes the per-residence cache entirely. + */ suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult { - // 1. Check residence-specific cache first - if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksByResidenceCacheTime[residenceId] ?: 0L)) { - val cached = DataManager.tasksByResidence.value[residenceId] - if (cached != null) { - return ApiResult.Success(cached) - } - } + val allTasksResult = getTasks(forceRefresh = forceRefresh) + if (allTasksResult is ApiResult.Error) return allTasksResult - // 2. Try filtering from allTasks cache before hitting API (optimization) - // This avoids a redundant API call when we already have all tasks loaded - if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksCacheTime)) { - val filtered = DataManager.getTasksForResidence(residenceId) - if (filtered != null) { - // Cache the filtered result for future use - DataManager.setTasksForResidence(residenceId, filtered) - return ApiResult.Success(filtered) - } - } - - // 3. Fallback: Fetch from API - val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) - val result = taskApi.getTasksByResidence(token, residenceId) - - // Update DataManager on success - if (result is ApiResult.Success) { - DataManager.setTasksForResidence(residenceId, result.data) - } - - return result + val filtered = DataManager.getTasksForResidence(residenceId) + ?: return ApiResult.Error("Tasks unavailable", 0) + return ApiResult.Success(filtered) } suspend fun createTask(request: TaskCreateRequest): ApiResult { @@ -667,9 +653,15 @@ object APILayer { /** * Atomically creates 1-50 tasks via POST /api/tasks/bulk/. The whole - * batch succeeds or fails together on the server. On success, every - * returned task is merged into DataManager.allTasks so observing views - * render the new batch immediately. + * batch succeeds or fails together on the server. On success, force- + * refreshes _allTasks from the server — the server is the + * authoritative kanban categorizer, and a single round-trip + * eliminates any drift between the per-task `kanbanColumn` hint and + * the global kanban view. + * + * This is the bug-class fix for gitea#2: the previous per-task + * updateTask loop was a no-op when _allTasks was null (fresh launch + * after onboarding), silently dropping the new tasks from cache. */ suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult { val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) @@ -677,7 +669,9 @@ object APILayer { if (result is ApiResult.Success) { DataManager.setTotalSummary(result.data.summary) - result.data.tasks.forEach { DataManager.updateTask(it) } + // Authoritative refresh — replaces any placeholder kanban + // shell from updateTask with proper server data. + getTasks(forceRefresh = true) } return result } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt index 8e1db03..9a3a850 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt @@ -70,15 +70,26 @@ class ResidenceViewModel( /** Drives the residence-scoped projections. */ private val _selectedResidenceId = MutableStateFlow(null) + /// Residence-scoped kanban derived from `DataManager.allTasks` filtered + /// by `_selectedResidenceId`. Single source of truth — eliminates the + /// gitea#2 race window where the per-residence cache slot could be + /// empty while `_allTasks` was populated. The per-residence cache + /// (`tasksByResidence`) was deleted in cec521b. val residenceTasksState: StateFlow> = - combine(_selectedResidenceId, dataManager.tasksByResidence) { id, map -> - if (id == null) ApiResult.Idle - else map[id]?.let { ApiResult.Success(it) } ?: ApiResult.Idle + combine(_selectedResidenceId, DataManager.allTasks) { id, all -> + 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.Eagerly, _selectedResidenceId.value?.let { id -> - dataManager.tasksByResidence.value[id]?.let { ApiResult.Success(it) } + DataManager.getTasksForResidence(id)?.let { ApiResult.Success(it) } } ?: ApiResult.Idle, ) diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt new file mode 100644 index 0000000..48d770f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt @@ -0,0 +1,167 @@ +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 + +/** + * Regression tests for the gitea#2 task-cache bug: + * `DataManager.updateTask` was a no-op when both `_allTasks` was null AND + * `_tasksByResidence[residenceId]` was empty — exactly the cache state + * after a fresh register-then-bulkCreateTasks flow. The just-created + * tasks would only appear after an app restart. + * + * After the fix, `updateTask` must seed `_allTasks` from empty rather + * than skipping the update. + */ +class DataManagerTaskCacheTest { + + @BeforeTest + fun resetState() { + DataManager.clear() + } + + /// Onboarding-flow scenario: brand-new user, fresh launch, no kanban + /// has ever been fetched, then a task arrives via bulkCreateTasks → + /// DataManager.updateTask. The new task MUST land in `_allTasks` and + /// be visible to any observer. + @Test + fun updateTask_seedsAllTasks_whenCacheIsEmpty() { + // Given: fresh DataManager, kanban never loaded + assertEquals(null, DataManager.allTasks.value, "_allTasks must start null after clear()") + + // When: a new task arrives via the same path bulkCreateTasks uses + DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks")) + + // Then: _allTasks must contain that task in the right column + val allTasks = DataManager.allTasks.value + assertNotNull(allTasks, "updateTask must seed _allTasks even when it was null") + + val upcoming = allTasks.columns.firstOrNull { it.name == "upcoming_tasks" } + assertNotNull(upcoming, "the seeded kanban must include an upcoming_tasks column") + assertTrue( + upcoming.tasks.any { it.id == 1 }, + "the new task must land in upcoming_tasks; got columns=${allTasks.columns.map { it.name to it.tasks.map { t -> t.id } }}" + ) + assertEquals(upcoming.tasks.size, upcoming.count, "column count must match tasks.size") + } + + /// Reasonable-defaults sanity check for the bulk-create scenario: + /// multiple tasks land across different kanban columns and end up + /// distributed correctly. This exercises the upsert when _allTasks + /// was seeded by a previous call. + @Test + fun updateTask_distributesAcrossColumns_whenSeedingThenAdding() { + DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "overdue_tasks")) + DataManager.updateTask(sampleTask(id = 2, residenceId = 100, column = "upcoming_tasks")) + DataManager.updateTask(sampleTask(id = 3, residenceId = 100, column = "upcoming_tasks")) + + val allTasks = DataManager.allTasks.value + assertNotNull(allTasks) + + val overdue = allTasks.columns.first { it.name == "overdue_tasks" } + val upcoming = allTasks.columns.first { it.name == "upcoming_tasks" } + + assertEquals(setOf(1), overdue.tasks.map { it.id }.toSet()) + assertEquals(setOf(2, 3), upcoming.tasks.map { it.id }.toSet()) + } + + /// Replacement contract: calling updateTask with the same id twice + /// must not duplicate; the second call replaces the first wherever it + /// lives. Catches the "always-append" implementation mistake. + @Test + fun updateTask_replacesExistingTaskById_acrossColumns() { + DataManager.updateTask(sampleTask(id = 5, residenceId = 100, column = "upcoming_tasks", title = "v1")) + DataManager.updateTask(sampleTask(id = 5, residenceId = 100, column = "in_progress_tasks", title = "v2")) + + val allTasks = DataManager.allTasks.value + assertNotNull(allTasks) + + val upcoming = allTasks.columns.first { it.name == "upcoming_tasks" } + val inProgress = allTasks.columns.first { it.name == "in_progress_tasks" } + + assertTrue(upcoming.tasks.none { it.id == 5 }, "task 5 must move out of upcoming_tasks") + assertEquals(1, inProgress.tasks.count { it.id == 5 }, "task 5 must appear once in in_progress_tasks") + assertEquals("v2", inProgress.tasks.first { it.id == 5 }.title) + } + + /// Characterization: getTasksForResidence filters _allTasks by + /// residence id. This is the helper that becomes the primary path + /// for residence-detail in Phase 3 (collapse the dual cache). + @Test + fun getTasksForResidence_filtersAllTasksByResidenceId() { + // Seed _allTasks with tasks across two residences via the upsert path. + DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks")) + DataManager.updateTask(sampleTask(id = 2, residenceId = 100, column = "overdue_tasks")) + DataManager.updateTask(sampleTask(id = 3, residenceId = 200, column = "upcoming_tasks")) + + val r100 = DataManager.getTasksForResidence(100) + assertNotNull(r100) + val r100Ids = r100.columns.flatMap { it.tasks }.map { it.id }.toSet() + assertEquals(setOf(1, 2), r100Ids) + + val r200 = DataManager.getTasksForResidence(200) + assertNotNull(r200) + val r200Ids = r200.columns.flatMap { it.tasks }.map { it.id }.toSet() + assertEquals(setOf(3), r200Ids) + + // Counts on each column must match the filtered task lists. + for (column in r100.columns) { + assertEquals(column.tasks.size, column.count, "column ${column.name} count mismatch") + } + } + + /// Characterization: residenceId with no tasks returns a non-null + /// shell so the residence-detail screen can distinguish "loading" + /// (null) from "loaded, no tasks" (non-null with empty columns). + @Test + fun getTasksForResidence_returnsEmptyShellForResidenceWithNoTasks() { + DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks")) + + val r999 = DataManager.getTasksForResidence(999) + assertNotNull(r999, "residence with no tasks must return an empty shell, not null") + assertEquals(0, r999.columns.sumOf { it.tasks.size }) + } + + /// Characterization: when _allTasks is null entirely (cache never + /// populated), getTasksForResidence returns null — caller must call + /// the API path. Phase 3's getTasksByResidence relies on this. + @Test + fun getTasksForResidence_returnsNullWhenAllTasksIsNull() { + DataManager.clear() + assertEquals(null, DataManager.getTasksForResidence(100)) + } + + /// Lockdown: updateTask must NOT touch `_tasksByResidence`. That cache + /// is being deleted in Phase 3; until then, updateTask must leave it + /// alone. If a future commit re-introduces the conditional write + /// branch this test catches it. + @Test + fun updateTask_doesNotMutate_tasksByResidence() { + val before = DataManager.tasksByResidence.value + DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks")) + assertEquals( + before, + DataManager.tasksByResidence.value, + "updateTask must not write to _tasksByResidence — that cache is deprecated" + ) + } + + private fun sampleTask( + id: Int, + residenceId: Int, + column: String, + title: String = "Task $id" + ) = TaskResponse( + id = id, + residenceId = residenceId, + createdById = 1, + title = title, + kanbanColumn = column, + createdAt = "2026-04-25T00:00:00Z", + updatedAt = "2026-04-25T00:00:00Z" + ) +} diff --git a/docs/plans/2026-04-25-task-cache-unification.md b/docs/plans/2026-04-25-task-cache-unification.md new file mode 100644 index 0000000..2d15035 --- /dev/null +++ b/docs/plans/2026-04-25-task-cache-unification.md @@ -0,0 +1,919 @@ +# Task Cache Unification Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make `_allTasks` the single source of truth for tasks; collapse `_tasksByResidence` into a derived view. Fix the bug where tasks created during onboarding don't appear on the Residence Detail screen until app restart (Gitea issue #2). + +**Architecture:** The current code has two parallel task caches that must be kept in sync (`_allTasks` for the kanban tab, `_tasksByResidence` per-residence for the residence detail screen). `DataManager.updateTask` is a no-op when either cache is empty, so post-`bulkCreateTasks` the new tasks live only on the server until something forces a fetch. After this change there is exactly one cache (`_allTasks`); residence detail screens observe it and apply an in-memory filter by `residenceId`. Mutations (`createTask`, `bulkCreateTasks`) force a refresh of `_allTasks` from the server to guarantee freshness with one round-trip instead of relying on conditional branches that silently skip. + +**Tech Stack:** Kotlin Multiplatform (commonMain), Ktor client, kotlinx.serialization, Combine bridge to SwiftUI iOS, Compose StateFlow on Android. Test framework: `kotlin.test` in commonTest. + +**Affected files:** +- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt` — remove `_tasksByResidence`, simplify `updateTask`/`removeTask`, add upsert behavior +- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt` — change `bulkCreateTasks`, `createTask`, `getTasksByResidence` +- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt` — feed `_residenceTasksState` from a `combine(allTasks, residenceId)` flow +- `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt` (new) — cache behavior tests +- `iosApp/iosApp/Task/TaskViewModel.swift` — drop `$tasksByResidence` sink, filter `$allTasks` when residence-scoped +- `iosApp/iosApp/Data/DataManagerObservable.swift` — drop `tasksByResidence` `@Published` and its `for await` task + +**Out of scope (do not touch):** +- Backend Go API — `/api/tasks/by-residence/:id/` endpoint stays in place untouched (might still be used by web admin) +- Android `ResidenceDetailScreen.kt` — the screen contract (`residenceViewModel.residenceTasksState`) is preserved; only the VM internals change +- Disk persistence schema — kotlinx.serialization is configured with `ignoreUnknownKeys` for forward/backward compat (verified in Task 11) + +--- + +## Pre-flight + +### Task 0: Verify clean state and baseline + +**Files:** none (read only) + +**Step 1: Confirm working tree is clean (or only the expected exception)** + +Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP status --short` +Expected: empty, **or** the only line is `M composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt`. That single file is intentionally on `Environment.LOCAL` for the duration of this work — it stays uncommitted and gets flipped back to `Environment.PROD` in Task 11 Step 5. If anything else shows up, stop and ask the user. + +**Step 2: Confirm we're on a feature branch (not master)** + +Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP rev-parse --abbrev-ref HEAD` +Expected: NOT `master`. If `master`, run `git checkout -b fix/task-cache-unification` before continuing. + +**Step 3: Run the existing commonTest baseline so we know what currently passes** + +Run: `./gradlew :composeApp:testDebugUnitTest` +Expected: BUILD SUCCESSFUL. Note the count — every later run must keep ≥ this count of green tests. + +**Step 4: Build iOS to confirm starting point compiles** + +Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20` +Expected: `** BUILD SUCCEEDED **` + +No commit — this is verification only. + +### Task 0.5: Failing regression XCUITest — reproduce the bug end-to-end + +**Goal:** Write a UI test that drives the exact onboarding-to-residence-detail flow from gitea#2 and asserts that tasks appear on the residence detail screen without an app restart. Run it now — it MUST fail. The Phase 1-3 fixes will make it pass; Task 12 re-runs it as the final gate. + +**Why before the unit tests:** The unit tests in Phase 1 catch the bug at the cache layer, but the *user-facing* bug is "I tap my residence and see 'no tasks'". A passing UI test is the only thing that proves the user experience is actually fixed. Writing it once up front + running it once at the end is cheaper than running a full UI cycle every iteration. + +**Files:** +- Create: `iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift` +- Maybe modify: `iosApp/HoneyDueUITests/AccessibilityIdentifiers.swift` (only if missing IDs are needed — see Step 2) +- Maybe modify: SwiftUI views in `iosApp/iosApp/` (only if the view layer is missing accessibility identifiers — see Step 2) + +**Pre-requisites already satisfied by Task 0 setup:** +- iOS app is on `Environment.LOCAL` +- Docker stack is up and healthy at `http://127.0.0.1:8000` +- `DEBUG=true` on the local API → email confirmation code is fixed at `123456` (saves a manual step in the test) + +**Step 1: Pick a clean run by wiping prior simulator state** + +Run: `xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev` +Run: `docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down && docker volume rm honeydueapi-go_postgres_data && docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d` +Wait until: `curl -fsS http://127.0.0.1:8000/api/health/` returns 200. + +**Step 2: Audit accessibility identifiers along the test path** + +The test taps and asserts on these SwiftUI surfaces. Open `iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift` and verify (or add) identifiers for each. Use stable, non-localized strings. + +Surfaces to identify: +| Where | Why the test needs it | Suggested ID constant | +|---|---|---| +| Login/Register screen — username, email, password, first/last name fields, "Register" button, "Verify" code field | Drive registration | already in `AccessibilityIdentifiers.Authentication.*` per existing UI tests — verify by grep | +| Onboarding — residence-creation form (name field + Continue) | Drive residence creation | `AccessibilityIdentifiers.Onboarding.residenceNameField`, `.continueButton` (add if missing) | +| Onboarding First-Task screen — "Browse All" tab button | Switch from suggestions to browse | `AccessibilityIdentifiers.Onboarding.browseAllTab` (add if missing) | +| Onboarding First-Task screen — each template row (selectable) | Pick 3 tasks | `AccessibilityIdentifiers.Onboarding.templateRowPrefix` (e.g., `"onboarding.template."`) — see how `OnboardingFirstTaskView.swift` renders rows; add an `.accessibilityIdentifier(...)` keyed on `template.id` | +| Onboarding First-Task screen — Submit button | Trigger bulk-create | `AccessibilityIdentifiers.Onboarding.submitTasksButton` (add if missing) | +| Residence list / home — the residence cell | Tap into detail | `AccessibilityIdentifiers.Residence.cellPrefix` (e.g., `"residence.cell."` or ``) — verify in `ResidenceListView` or wherever the post-onboarding landing screen renders cells | +| Residence detail — task row | Assert presence | `AccessibilityIdentifiers.Task.rowPrefix` (e.g., `"task.row"`) — verify the task list inside `TasksSectionContainer` in `ResidenceDetailView.swift:538` | +| Residence detail — empty state ("No tasks" copy) | Assert ABSENCE | `AccessibilityIdentifiers.Task.noTasksLabel` (add if missing) — find the empty-state copy in the residence-detail tasks section and pin an identifier on it | + +For each missing ID, add it in two places: +1. The constant in `AccessibilityIdentifiers.swift` +2. `.accessibilityIdentifier(AccessibilityIdentifiers.X.Y)` on the SwiftUI view + +Keep these app-side additions in **a single dedicated commit** so reviewers can see "test scaffolding only, no behavior change": + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add -A +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: add accessibility identifiers along the onboarding-to-residence-detail path + +Scaffolding for the gitea#2 regression XCUITest. No user-visible +change — pure metadata for UI automation." +``` + +**Step 3: Write the failing UI test** + +Create `iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift`: + +```swift +import XCTest + +/// Regression test for gitea#2. +/// +/// Onboarding flow: register → create residence → pick 3 tasks → submit. +/// After submit, the user lands on the home/residences screen. They tap +/// the new residence WITHOUT visiting the Tasks tab first (the Tasks tab +/// triggers a `getTasks()` that masks the bug by populating `_allTasks`). +/// +/// Expected: residence detail shows ≥1 task row within 10s. +/// Pre-fix: residence detail shows empty state ("no tasks") forever +/// until the app is restarted. +final class Suite11_TaskCacheRegressionTests: XCTestCase { + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart() throws { + let app = XCUIApplication() + app.launchArguments += ["UI-Testing"] + app.launch() + + // 1. Register a fresh user. Email confirmation code is fixed at "123456" + // in DEBUG mode (DEBUG_FIXED_CODES=true on the local docker stack). + let stamp = String(Int(Date().timeIntervalSince1970)) + UITestHelpers.register( + in: app, + username: "uitest\(stamp)", + email: "uitest+\(stamp)@treymail.com", + password: "UItest\(stamp)!aZ", + confirmationCode: "123456" + ) + + // 2. Onboarding: create the residence. + UITestHelpers.completeResidenceCreation(in: app, name: "UI Test Property") + + // 3. Switch to "Browse All" tab and pick 3 templates. The "For You" + // suggestions tab depends on a server-side recommendation that + // might be empty for a freshly created residence; Browse is + // deterministic. + let browseTab = app.buttons[AccessibilityIdentifiers.Onboarding.browseAllTab] + XCTAssertTrue(browseTab.waitForExistence(timeout: 5), + "Browse All tab must appear on First-Task screen") + browseTab.tap() + + let templates = app.buttons.matching( + NSPredicate(format: "identifier BEGINSWITH %@", + AccessibilityIdentifiers.Onboarding.templateRowPrefix) + ) + + // Wait for the catalog to load — fresh API call against local backend. + XCTAssertTrue(templates.element(boundBy: 0).waitForExistence(timeout: 10), + "Template catalog must load") + + for i in 0..<3 { + templates.element(boundBy: i).tap() + } + + // 4. Submit. This calls APILayer.bulkCreateTasks → POST /api/tasks/bulk/ + // The bug lives in the cache update path between this call returning + // and the residence detail screen rendering. + let submit = app.buttons[AccessibilityIdentifiers.Onboarding.submitTasksButton] + XCTAssertTrue(submit.waitForExistence(timeout: 3)) + submit.tap() + + // 5. Land on home/residences. Tap the residence we just created. + // Critical: do NOT visit the Tasks tab — that would call getTasks() + // and populate _allTasks via setAllTasks, masking the bug. + let residenceCell = app.buttons[ + AccessibilityIdentifiers.Residence.cellPrefix + "UI Test Property" + ] + XCTAssertTrue(residenceCell.waitForExistence(timeout: 10), + "Residence cell must appear on home after onboarding submit") + residenceCell.tap() + + // 6. Residence detail must show ≥1 task row, NOT the empty state. + // Generous timeout (10s) covers the network round-trip on slow + // local Docker startups. + let firstTaskRow = app.cells + .matching(NSPredicate(format: "identifier BEGINSWITH %@", + AccessibilityIdentifiers.Task.rowPrefix)) + .firstMatch + XCTAssertTrue( + firstTaskRow.waitForExistence(timeout: 10), + "Tasks created during onboarding must appear on residence detail without restart (gitea#2)" + ) + + let emptyState = app.staticTexts[AccessibilityIdentifiers.Task.noTasksLabel] + XCTAssertFalse( + emptyState.exists, + "Empty 'no tasks' state must NOT show when tasks exist (gitea#2)" + ) + + // 7. Cleanup — delete the test user via UI (or skip; clearing the + // docker volume between runs is the cheaper reset). + } +} +``` + +**Notes for the engineer writing this test:** + +- `UITestHelpers.register(...)` and `UITestHelpers.completeResidenceCreation(...)` may not exist verbatim — read `iosApp/HoneyDueUITests/UITestHelpers.swift` for the existing helpers. If `register(...)` exists but doesn't take a `confirmationCode:` arg, either add one or inline the verification step. +- DO NOT use `sleep()` anywhere. Use `waitForExistence(timeout:)` everywhere. The skill `axiom-ui-testing` is loaded if you need patterns. +- `continueAfterFailure = false` so we stop at the exact assertion that fails — easier to triage video. +- If you can't get a residence cell identifier reliably (e.g., the home screen shows a custom layout, not standard cells), substitute `app.staticTexts["UI Test Property"]` and tap that. The point is to land on the residence detail without going through the Tasks tab. + +**Step 4: Run the test — must FAIL** + +Run: +``` +xcodebuild -project iosApp/honeyDue.xcodeproj \ + -scheme HoneyDueUITests \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \ + test 2>&1 | tail -40 +``` + +Expected: `Test Suite '...' failed.` with the assertion **"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"**. + +If it FAILS for a different reason (residence cell not found, timeout on browse tab, etc.) → that's an accessibility-identifier mismatch, not a bug repro. Fix the test/IDs and re-run. The test must fail SPECIFICALLY on the "no tasks on residence detail" assertion to be a valid bug capture. + +If it PASSES → the bug isn't reproducing in this environment. Possibilities: +- App was already cached with `_allTasks` from a prior run (re-run Step 1 to fully wipe simulator + DB) +- The user navigated through the Tasks tab implicitly (check the home screen layout) +- The bug only happens on a code path you didn't replicate (re-read the iOS-side onboarding flow) + +**Step 5: Commit the failing test** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: failing — onboarding tasks must appear on residence detail without restart + +Captures gitea#2 at the user-visible level. The kanban tab works but +the residence detail screen does not, until the app is restarted. This +test must FAIL at this commit and PASS after the cache unification work. +Re-run gates the merge in Task 12." +``` + +The test stays failing through Phase 1-3 commits. Don't run it on every commit — it's slow. Run it once at the end (Task 12). + +--- + +## Phase 1 — TDD: catch the bug, then fix it + +### Task 1: Failing test — `bulkCreateTasks` must populate `_allTasks` + +**Files:** +- Create: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt` + +**Step 1: Write the failing test** + +This test reproduces the onboarding bug at the cache layer. We can't easily mock Ktor here without infrastructure, so we test the cache mutation contract directly: after a successful bulk-create, `_allTasks` MUST contain every returned task, regardless of prior cache state. + +```kotlin +package com.tt.honeyDue.data + +import com.tt.honeyDue.models.TaskResponse +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.BeforeTest + +class DataManagerTaskCacheTest { + + @BeforeTest + fun resetState() { + DataManager.clearAllData() + } + + @Test + fun `updateTask seeds _allTasks when cache is empty`() { + // Given: fresh DataManager with no tasks loaded (the onboarding scenario) + assertEquals(null, DataManager.allTasks.value) + + // When: a new task arrives via the same path bulkCreateTasks uses + val task = TaskResponse( + id = 1, + residenceId = 100, + title = "Replace HVAC filter", + kanbanColumn = "upcoming_tasks", + // ... fill remaining required TaskResponse fields with sensible defaults + ) + DataManager.updateTask(task) + + // Then: _allTasks is populated with this task in the right column + val allTasks = DataManager.allTasks.value + assertNotNull(allTasks, "updateTask must seed _allTasks even when it was null") + + val upcomingColumn = allTasks.columns.firstOrNull { it.name == "upcoming_tasks" } + assertNotNull(upcomingColumn) + assertTrue( + upcomingColumn.tasks.any { it.id == 1 }, + "task must land in the upcoming_tasks column" + ) + } +} +``` + +You'll need to look at `TaskResponse` in `composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/CustomTask.kt` to fill the required fields. Use defaults that match an onboarding-created task (no completion, no priority, due-soon date). + +**Step 2: Run the test — must FAIL** + +Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask seeds _allTasks when cache is empty"` +Expected: FAIL with `expected: but was:` (or similar). This proves the test catches the bug. + +**Step 3: Commit the failing test** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: failing — DataManager.updateTask must seed _allTasks" +``` + +### Task 2: Make `DataManager.updateTask` a real upsert + +**Files:** +- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt:482-520` + +**Step 1: Replace the conditional branch on `_allTasks` with an upsert** + +Current (lines 484-498): +```kotlin +_allTasks.value?.let { current -> + val targetColumn = task.kanbanColumn ?: "upcoming_tasks" + val newColumns = current.columns.map { column -> ... } + _allTasks.value = current.copy(columns = newColumns) +} +``` + +Replace with: +```kotlin +val targetColumn = task.kanbanColumn ?: "upcoming_tasks" +val current = _allTasks.value ?: TaskColumnsResponse( + columns = standardKanbanColumns(), // see Step 2 + daysThreshold = 30, + residenceId = null +) +val newColumns = current.columns.map { column -> + val filteredTasks = column.tasks.filter { it.id != task.id } + val updatedTasks = if (column.name == targetColumn) filteredTasks + task else filteredTasks + column.copy(tasks = updatedTasks, count = updatedTasks.size) +} +// If targetColumn doesn't exist in current.columns (e.g. fresh seed), append it +val finalColumns = if (newColumns.none { it.name == targetColumn }) { + newColumns + Column(name = targetColumn, tasks = listOf(task), count = 1, /* fill rest */) +} else newColumns +_allTasks.value = current.copy(columns = finalColumns) +``` + +**Step 2: Add `standardKanbanColumns()` helper** + +Look at the backend response — `internal/repositories/task_repo.go` `GetKanbanDataForMultipleResidences` defines the column order. Mirror it in Kotlin: + +```kotlin +private fun standardKanbanColumns(): List = listOf( + Column(name = "overdue_tasks", tasks = emptyList(), count = 0, /* defaults */), + Column(name = "due_soon_tasks", tasks = emptyList(), count = 0, /* defaults */), + Column(name = "in_progress_tasks", tasks = emptyList(), count = 0, /* defaults */), + Column(name = "upcoming_tasks", tasks = emptyList(), count = 0, /* defaults */), + Column(name = "completed_tasks", tasks = emptyList(), count = 0, /* defaults */), + // archived/cancelled are hidden from kanban — see honeyDueAPI-go/CLAUDE.md +) +``` + +Look at `Column` in `CustomTask.kt` for its required fields (display label, color, etc.). Fill in matching defaults. + +**Step 3: Drop the second branch (`_tasksByResidence`) — it's going away in Phase 3** + +Remove lines 500-515 entirely. The `_tasksByResidence` slot is still there for now (Phase 3 deletes it), but `updateTask` should not write to it any more. + +**Step 4: Run the test — must PASS** + +Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask seeds _allTasks when cache is empty"` +Expected: PASS + +**Step 5: Run the full test suite to confirm no regressions** + +Run: `./gradlew :composeApp:testDebugUnitTest` +Expected: same green count as Task 0 baseline + 1 new test. + +**Step 6: Commit** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "fix: DataManager.updateTask seeds _allTasks when cache is empty + +Closes the silent no-op when _allTasks is null on first launch (the +onboarding bulkCreateTasks path). The function now upserts: builds an +empty kanban shell if needed and places the task in its target column. +Adds an unknown column at the end for forward compatibility with future +column names from the server. + +Refs gitea#2" +``` + +### Task 3: Add upsert test for `_tasksByResidence` deletion guard + +**Files:** +- Modify: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt` + +**Step 1: Add a test asserting `updateTask` does NOT touch `_tasksByResidence` any more** + +```kotlin +@Test +fun `updateTask no longer mutates _tasksByResidence`() { + val before = DataManager.tasksByResidence.value + DataManager.updateTask(/* sample task */) + assertEquals(before, DataManager.tasksByResidence.value, + "updateTask must not touch _tasksByResidence — it's deprecated") +} +``` + +**Step 2: Run — must PASS** (we already removed the branch in Task 2) + +Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask no longer mutates _tasksByResidence"` +Expected: PASS + +**Step 3: Commit** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: lock down that updateTask no longer writes _tasksByResidence" +``` + +--- + +## Phase 2 — Belt-and-suspenders: force-refresh after mutations + +The Phase 1 upsert handles the fresh-cache case correctly, but it makes assumptions about kanban column placement based on the response's `kanbanColumn` field. The server is the authoritative kanban categorizer. To eliminate any drift, also force a `_allTasks` refresh after multi-task mutations. + +### Task 4: Force `_allTasks` refresh after `bulkCreateTasks` + +**Files:** +- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt:647-655` + +**Step 1: Add post-success refresh** + +Current: +```kotlin +suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult { + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) + val result = taskApi.bulkCreateTasks(token, request) + + if (result is ApiResult.Success) { + DataManager.setTotalSummary(result.data.summary) + result.data.tasks.forEach { DataManager.updateTask(it) } + } + return result +} +``` + +New: +```kotlin +suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult { + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) + val result = taskApi.bulkCreateTasks(token, request) + + if (result is ApiResult.Success) { + DataManager.setTotalSummary(result.data.summary) + // Authoritative refresh — server knows the right kanban placement. + // Cheap (one round-trip) and eliminates any client-side drift between + // the per-task kanbanColumn hint and the global kanban view. + getTasks(forceRefresh = true) + } + return result +} +``` + +Drop the `forEach { updateTask }` — it becomes redundant with the force-refresh. + +**Step 2: Run the full test suite** + +Run: `./gradlew :composeApp:testDebugUnitTest` +Expected: all green (the Phase 1 upsert tests still pass because they exercise `updateTask` directly, not `bulkCreateTasks`). + +**Step 3: Commit** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "fix: bulkCreateTasks force-refreshes _allTasks instead of merging task-by-task + +Server is the authoritative kanban categorizer. After a bulk insert, +re-fetch /api/tasks/ so the kanban view reflects exactly what the +server sees, including any column re-categorizations the client's +in-memory upsert wouldn't compute. One extra round-trip per onboarding +submission, called once per session typically. + +Refs gitea#2" +``` + +--- + +## Phase 3 — Collapse the dual cache + +### Task 5: Characterization test for `getTasksForResidence` + +`getTasksForResidence` already implements the filter we want to use everywhere. Lock it down with a test before we make it the primary path. + +**Files:** +- Modify: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt` + +**Step 1: Add the test** + +```kotlin +@Test +fun `getTasksForResidence filters _allTasks by residence id`() { + DataManager.setAllTasks(/* response with tasks across residences 100 and 200 */) + + val r100 = DataManager.getTasksForResidence(100) + assertNotNull(r100) + assertTrue(r100.columns.flatMap { it.tasks }.all { it.residenceId == 100 }) + + val r999 = DataManager.getTasksForResidence(999) + assertNotNull(r999) + assertEquals(0, r999.columns.sumOf { it.tasks.size }) // valid id, just no tasks +} + +@Test +fun `getTasksForResidence returns null when _allTasks is null`() { + DataManager.clearAllData() + assertEquals(null, DataManager.getTasksForResidence(100)) +} +``` + +**Step 2: Run — must PASS** (no implementation change yet) + +Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.getTasksForResidence*"` +Expected: PASS for both. + +**Step 3: Commit** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: characterize getTasksForResidence filter contract" +``` + +### Task 6: Simplify `APILayer.getTasksByResidence` + +**Files:** +- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt:591-621` + +**Step 1: Replace the 3-path implementation with "ensure _allTasks fresh, then filter"** + +```kotlin +suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult { + // Ensure _allTasks is loaded and reasonably fresh. + // getTasks itself respects forceRefresh and the global tasksCacheTime. + val allTasksResult = getTasks(forceRefresh = forceRefresh) + if (allTasksResult is ApiResult.Error) return allTasksResult + + val filtered = DataManager.getTasksForResidence(residenceId) + ?: return ApiResult.Error("Tasks unavailable", 0) + return ApiResult.Success(filtered) +} +``` + +This deletes the per-residence cache reliance entirely. `_tasksByResidence` is no longer written by this path. + +**Step 2: Run the test suite** + +Run: `./gradlew :composeApp:testDebugUnitTest` +Expected: all green. + +**Step 3: Commit** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "refactor: getTasksByResidence is now a thin filter over _allTasks + +Was 3 fallback paths (per-residence cache → filter from allTasks → +network). Now: ensure _allTasks fresh, return filter. The per-residence +cache becomes write-only by this path, scheduled for deletion in the +next commit." +``` + +### Task 7: iOS — `TaskViewModel` observes `$allTasks` with filter + +**Files:** +- Modify: `iosApp/iosApp/Task/TaskViewModel.swift:46-74` + +**Step 1: Replace dual-sink with single-sink + filter** + +Current logic uses two Combine sinks: `$allTasks` (only when `currentResidenceId == nil`) and `$tasksByResidence` (only when set). + +Replace with one sink on `$allTasks` that conditionally filters: + +```swift +DataManagerObservable.shared.$allTasks + .receive(on: DispatchQueue.main) + .sink { [weak self] allTasks in + guard let self else { return } + guard !self.isAnimatingCompletion else { return } + + if let allTasks { + if let resId = self.currentResidenceId { + self.tasksResponse = self.filterByResidence(allTasks, residenceId: resId) + } else { + self.tasksResponse = allTasks + } + self.isLoadingTasks = false + } + } + .store(in: &cancellables) +``` + +**Step 2: Add the `filterByResidence` helper** + +```swift +private func filterByResidence(_ response: TaskColumnsResponse, residenceId: Int32) -> TaskColumnsResponse { + let filteredColumns = response.columns.map { column -> Column in + let filteredTasks = column.tasks.filter { Int32($0.residenceId ?? 0) == residenceId } + return column.copy(tasks: filteredTasks, count: Int32(filteredTasks.count)) + } + return response.copy(columns: filteredColumns, residenceId: String(residenceId)) +} +``` + +(Use `.doCopy(...)` SKIE syntax if `.copy` isn't directly callable from Swift — check what other Swift code does with TaskColumnsResponse copies.) + +**Step 3: Drop the `$tasksByResidence` subscription block entirely** + +Remove the second `.sink` on `$tasksByResidence` (currently lines 62-74). + +**Step 4: Build iOS** + +Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20` +Expected: `** BUILD SUCCEEDED **` + +**Step 5: Run iOS unit tests** + +Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:HoneyDueTests test 2>&1 | tail -20` +Expected: TEST SUCCEEDED. + +**Step 6: Commit** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add iosApp/iosApp/Task/TaskViewModel.swift +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "ios: TaskViewModel observes \$allTasks and filters by residence in-memory + +Single source of truth eliminates the race window where the residence +detail screen could mount before the per-residence cache slot existed. +After this, every emit of _allTasks rerenders every observing view — +kanban tab, residence detail, dashboards — atomically. + +Refs gitea#2" +``` + +### Task 8: Android — `ResidenceViewModel` feeds `residenceTasksState` from a combined flow + +**Files:** +- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt:31-94` + +**Step 1: Replace the imperative `loadResidenceTasks` with a derived flow** + +Look at how `_residenceTasksState` is currently populated (line 88-94). Instead of imperatively calling `APILayer.getTasksByResidence` and storing the result, derive it: + +```kotlin +private val _currentResidenceId = MutableStateFlow(null) + +val residenceTasksState: StateFlow> = + combine(DataManager.allTasks, _currentResidenceId) { all, id -> + when { + id == null -> ApiResult.Idle + all == null -> ApiResult.Loading + else -> { + val filtered = DataManager.getTasksForResidence(id) + if (filtered != null) ApiResult.Success(filtered) else ApiResult.Loading + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ApiResult.Idle) + +fun loadResidenceTasks(residenceId: Int, forceRefresh: Boolean = false) { + viewModelScope.launch { + _currentResidenceId.value = residenceId + // Trigger the underlying _allTasks refresh; the combine above + // re-emits Success when allTasks arrives. + APILayer.getTasks(forceRefresh = forceRefresh) + } +} +``` + +The screen contract (`residenceViewModel.residenceTasksState`) is preserved — `ResidenceDetailScreen.kt:59` doesn't need any change. + +**Step 2: Build Android debug** + +Run: `./gradlew :composeApp:assembleDebug` +Expected: BUILD SUCCESSFUL. + +**Step 3: Run commonTest again** + +Run: `./gradlew :composeApp:testDebugUnitTest` +Expected: all green. (`ResidenceViewModelTest` may need adjusting — check it.) + +**Step 4: Commit** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt +# Also stage any test fixes +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "android: ResidenceViewModel.residenceTasksState derives from _allTasks + +Same screen contract, but the data flows from DataManager.allTasks +through a combine(...) into the existing StateFlow. No per-residence +network call needed; the upstream getTasks() refresh propagates." +``` + +### Task 9: Delete dead code — `_tasksByResidence` and friends + +**Files:** +- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt` +- Modify: `iosApp/iosApp/Data/DataManagerObservable.swift` + +**Step 1: In DataManager.kt, delete:** + +- `_tasksByResidence` (line 141) and `tasksByResidence` (line 142) +- `tasksByResidenceCacheTime` (line 65) +- `setTasksForResidence` (lines 448-451) +- `invalidateTasksFor` (line 417 — verify it has no other callers first via grep) +- `_tasksByResidence` mutations in `removeTask` (lines 533-onwards) — keep only the `_allTasks` removal +- `_tasksByResidence.value = emptyMap()` in clearAllData and similar wipes (lines 783, 836) +- `tasksByResidenceCacheTime.clear()` in same wipes (lines 814, 849) + +Keep `getTasksForResidence` — it's the public filter API, still used by the new `getTasksByResidence` and Android VM. + +**Step 2: In DataManagerObservable.swift, delete:** + +- `@Published var tasksByResidence: [Int32: TaskColumnsResponse] = [:]` (line 44) +- The `for await tasks in DataManager.shared.tasksByResidence` task (lines 195-201) +- The `tasksByResidence[residenceId]` reader at line 524 (replace with `DataManager.shared.getTasksForResidence(residenceId)` or its iOS-friendly equivalent if anything still calls this — grep first) + +**Step 3: Compile both targets** + +Run: `./gradlew :composeApp:assembleDebug && ./gradlew :composeApp:testDebugUnitTest` +Then: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20` +Expected: both BUILD SUCCEEDED, all tests green. + +If anything fails to compile, follow the compiler — there's likely a missed reader. Common suspects: `TaskViewModel.kt` (Kotlin VM, not Swift) line ~38-42 references `_tasksByResidenceState`; verify it's still wired correctly or also delete it. + +**Step 4: Commit** + +```bash +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add -A +git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "refactor: delete _tasksByResidence and per-residence task cache plumbing + +All readers and writers gone after the previous commits. Single source +of truth = DataManager._allTasks, residence views derive via +getTasksForResidence(id). Net deletion ~100 LOC across DataManager, +APILayer, DataManagerObservable, and iOS TaskViewModel. + +Closes gitea#2" +``` + +--- + +## Phase 4 — Verification + +### Task 10: Verify disk persistence is forward-compatible + +**Files:** none (verification only) + +**Step 1: Find the persistence model** + +Run: `grep -rn "ignoreUnknownKeys\|Json {\|tasksByResidence" composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/PersistenceManager.kt` +Expected: kotlinx.serialization Json config with `ignoreUnknownKeys = true`. If NOT, an existing user upgrading the app will crash on first launch when the persisted blob has the now-removed `tasksByResidence` field. + +**Step 2: If `ignoreUnknownKeys` is missing, ADD IT BEFORE SHIPPING** + +Edit the Json config: +```kotlin +val json = Json { + ignoreUnknownKeys = true + // ... existing config +} +``` +Commit separately as `chore: persistence Json must ignoreUnknownKeys`. + +**Step 3: Manual test — wipe simulator app data, install old app, then this version** + +If you have a TestFlight build of the previous version: +1. Install old version → register → create residence → quit +2. Update to this build → launch → confirm no crash, data loads +3. Quit → relaunch → confirm persistence works correctly + +If no old TestFlight build available, skip this empirical check but the `ignoreUnknownKeys` setting is sufficient. + +### Task 11: Manual smoke — the actual bug repro + +**Files:** none (manual test) + +**Step 1: Wipe simulator state for the dev build** + +Run: `xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev` + +**Step 2: Confirm iOS is on LOCAL (set during pre-flight, stays uncommitted)** + +Run: `grep "CURRENT_ENV" /Users/treyt/Desktop/code/honeyDue/honeyDueKMP/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt` +Expected: `val CURRENT_ENV = Environment.LOCAL`. If it's not, edit the file. DO NOT COMMIT — revert in Step 5. + +**Step 3: Build and install** + +Run: `./gradlew :composeApp:assembleDebug` and Xcode build to simulator. + +**Step 4: Reproduce the original bug path** + +1. Launch app → land on register screen +2. Register a fresh user with a unique email +3. Onboarding: create residence → choose 3+ tasks from the catalog → submit +4. Land on home/dashboard +5. Navigate to the new residence's detail screen WITHOUT visiting the Tasks tab first +6. **Expected: tasks visible immediately. No "no tasks" state. No restart needed.** + +If the bug still reproduces, return to Phase 1 — the upsert or refresh isn't working. Capture iOS console with `xclog launch com.myhoneydue.honeyDue.dev` and inspect. + +**Step 5: Revert the ApiConfig change** + +Edit `ApiConfig.kt` back to `Environment.PROD` (or whatever it was). Confirm with `git diff`. Do not commit. + +### Task 12: Final regression sweep + +**Files:** none (verification only) + +**Step 1: Full Kotlin test suite green** + +Run: `./gradlew :composeApp:testDebugUnitTest` + +**Step 2: iOS unit tests green** + +Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme HoneyDue -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:HoneyDueTests test 2>&1 | tail -20` + +**Step 3: The Task 0.5 regression XCUITest now passes** + +Wipe state for a clean run (mirrors Task 0.5 Step 1): +``` +xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev +docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down +docker volume rm honeydueapi-go_postgres_data +docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d +``` + +Wait for `curl -fsS http://127.0.0.1:8000/api/health/` → 200. Then re-run the regression test: + +``` +xcodebuild -project iosApp/honeyDue.xcodeproj \ + -scheme HoneyDueUITests \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \ + test 2>&1 | tail -20 +``` + +Expected: **TEST SUCCEEDED**. The "Tasks created during onboarding must appear on residence detail without restart" assertion now holds. + +**If it FAILS:** the cache fix is incomplete. Inspect the test report video (`xcrun xcresulttool get --path build/reports/...xcresult ...`) and follow the failure point. Common causes: missed updateTask call site in the dual-cache deletion, a residual reader of `_tasksByResidence` in iOS not pruned, or a race between `getTasks(forceRefresh=true)` and the residence detail's first observation. **DO NOT** weaken the test to make it pass — fix the underlying issue. + +**Step 4: Stress run (catch flakiness before merge)** + +Run the test 5× to confirm it's stable, not just lucky: +``` +for i in 1 2 3 4 5; do + xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev + docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down >/dev/null + docker volume rm honeydueapi-go_postgres_data >/dev/null + docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d >/dev/null + until curl -fsS http://127.0.0.1:8000/api/health/ >/dev/null 2>&1; do sleep 2; done + xcodebuild -project iosApp/honeyDue.xcodeproj -scheme HoneyDueUITests -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \ + test 2>&1 | tail -3 + echo "=== run $i done ===" +done +``` + +Expected: 5/5 TEST SUCCEEDED. If even one fails, treat as flaky — don't merge. + +**Step 5: Diff summary** + +Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP diff --stat master...HEAD` +Expected: net deletion ~80-150 lines across the listed files. If the diff is much larger, scope creep — review commits. + +**Step 6: Push and open a PR (only if user confirms)** + +Don't push without asking the user. Wait for explicit go-ahead. + +--- + +## Rollback plan + +If anything goes sideways in production: +1. `git revert ` — every commit in this plan is independently revertable in reverse order, but the cleanest rollback is reverting the merge commit. +2. Old persistence blob format is preserved by `ignoreUnknownKeys` — no migration required. +3. Backend `/api/tasks/by-residence/:id/` was never touched, so a rolled-back client immediately starts using it again with no server change. + +--- + +## Notes for the executing engineer + +- **Frequent commits.** Every task ends with a commit. If you deviate from the plan, commit before deviating. +- **Don't auto-commit any other changes.** Per `honeyDueKMP/CLAUDE.md`: "DO NOT auto-commit code changes." Commit only what's specified. +- **Don't push to remote.** Let the user trigger the push after they review. +- **TaskColumnsResponse fields.** The `Column` data class in `CustomTask.kt` may have more fields than shown (display label, color, sort order). Read it before writing the standard column shell in Task 2 — the test will fail on missing required constructor args. +- **TaskResponse fields.** Same — has many fields. For test fixtures, build a small helper: + ```kotlin + private fun sampleTask(id: Int, residenceId: Int, column: String) = TaskResponse(...) + ``` + in the test file rather than repeating the giant constructor. +- **SKIE/Swift copy.** TaskColumnsResponse `.copy()` from Swift may need `.doCopy(...)` if SKIE renamed it. Check `iosApp/iosApp/Task/TaskViewModel.swift` line 387 onward for an existing example of how Swift copies a Kotlin data class. +- **Don't refactor "while you're here."** This plan is laser-focused on the cache unification. Other smells you spot — log them, don't fix them in this PR. diff --git a/gradle.properties b/gradle.properties index 5c4ccd0..d80cc14 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,9 +1,17 @@ #Kotlin kotlin.code.style=official -kotlin.daemon.jvmargs=-Xmx3072M +# Heap sizing for KMP builds. +# Kotlin daemon runs the K2 compiler + native linker; 4 GB headroom +# prevents long-tail OOMs during iosArm64 framework link. +# MaxMetaspaceSize caps slow class-loading leaks across daemon reuse; +# G1GC keeps pauses short during incremental builds. +kotlin.daemon.jvmargs=-Xmx4096M -XX:MaxMetaspaceSize=1g -XX:+UseG1GC #Gradle -org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 +# Gradle daemon drives configuration cache + dependency resolution + +# Compose/Android compilers. OOMs at 4 GB during ComposeApp.framework +# generation; 6 GB is the usual safe size for projects this size. +org.gradle.jvmargs=-Xmx6144M -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -Dfile.encoding=UTF-8 org.gradle.configuration-cache=true org.gradle.caching=true diff --git a/iosApp/ExportOptions.plist b/iosApp/ExportOptions.plist index 50b53a4..0a30d09 100644 --- a/iosApp/ExportOptions.plist +++ b/iosApp/ExportOptions.plist @@ -15,6 +15,6 @@ manageAppVersionAndBuildNumber teamID - V3PF3M6B6U + X86BR9WTLD diff --git a/iosApp/HoneyDue/AppIntent.swift b/iosApp/HoneyDue/AppIntent.swift index 5570cc4..10796d4 100644 --- a/iosApp/HoneyDue/AppIntent.swift +++ b/iosApp/HoneyDue/AppIntent.swift @@ -148,7 +148,7 @@ final class WidgetActionManager { static let shared = WidgetActionManager() private let appGroupIdentifier: String = { - Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev" + Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev" }() private let pendingTasksFileName = "widget_pending_tasks.json" private let tokenKey = "widget_auth_token" diff --git a/iosApp/HoneyDue/HoneyDue.swift b/iosApp/HoneyDue/HoneyDue.swift index 924e3c2..2e49928 100644 --- a/iosApp/HoneyDue/HoneyDue.swift +++ b/iosApp/HoneyDue/HoneyDue.swift @@ -111,7 +111,7 @@ class CacheManager { } private static let appGroupIdentifier: String = { - Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev" + Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev" }() private static let tasksFileName = "widget_tasks.json" diff --git a/iosApp/HoneyDueUITests.xctestplan b/iosApp/HoneyDueUITests.xctestplan index 550136a..aacf77d 100644 --- a/iosApp/HoneyDueUITests.xctestplan +++ b/iosApp/HoneyDueUITests.xctestplan @@ -9,13 +9,13 @@ } ], "defaultOptions" : { - "testTimeoutsEnabled" : true, "defaultTestExecutionTimeAllowance" : 300, "targetForVariableExpansion" : { "containerPath" : "container:honeyDue.xcodeproj", "identifier" : "D4ADB376A7A4CFB73469E173", "name" : "HoneyDue" - } + }, + "testTimeoutsEnabled" : true }, "testTargets" : [ { diff --git a/iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift b/iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift new file mode 100644 index 0000000..a9699ab --- /dev/null +++ b/iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift @@ -0,0 +1,221 @@ +import XCTest + +/// Suite 11 — captures the gitea#2 regression at the user-visible level: +/// after onboarding (register → name residence → bulk-create tasks → land +/// on home), tapping the residence cell shows "no tasks" even though the +/// server has them. Restarting the app fixes it. This test reproduces the +/// flow without an app restart and asserts that tasks render on the +/// residence detail screen. +/// +/// CRITICAL: this test must FAIL at the cache-unification fix's first +/// commit and must PASS after Phase 1-3 lands. The failing assertion is +/// pinned to a specific message so the regression is unambiguous. +/// +/// The test deliberately does NOT visit the Tasks tab between onboarding +/// and tapping the residence cell. Visiting the Tasks tab would prime +/// `_allTasks` and mask the bug — the bug is that residence detail +/// cannot recover from the empty-cache + sink-timing window on its own. +final class Suite11_TaskCacheRegressionTests: BaseUITestCase { + // We need to start at the onboarding welcome screen, not the standalone + // login screen — `completeOnboarding` would skip the entire flow. + override var completeOnboarding: Bool { false } + // Single test in this suite — relaunch isn't necessary, but we want a + // clean state every time (handled by the default --reset-state). + override var relaunchBetweenTests: Bool { true } + + // MARK: - Constants + + /// DEBUG_FIXED_CODES=true on the local Go API hardcodes this code. + private let debugVerificationCode = "123456" + + /// Stable name for the residence we create in onboarding. Used both for + /// the form input and to address the cell on the home screen via + /// `app.staticTexts[residenceName]` if the id-based identifier doesn't + /// resolve in time. + private let residenceName = "UI Test Property" + + // MARK: - Test + + /// Reproduces gitea#2: tasks created via the onboarding bulk endpoint + /// must appear on the residence detail screen without an app restart + /// and without first visiting the Tasks tab. + @MainActor + func test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart() throws { + // Step 1 — Register a fresh user via the onboarding Start Fresh flow. + // The flow is: Welcome → ValueProps → NameResidence → CreateAccount + // → VerifyEmail → HomeProfile → FirstTask → main app. + let createAccount = TestFlows.navigateStartFreshToCreateAccount( + app: app, + residenceName: residenceName + ) + createAccount.waitForLoad(timeout: navigationTimeout) + + // Step 2 — Fill the create-account form. We address the onboarding + // form's fields (not the standalone register sheet's fields). + let creds = TestAccountManager.uniqueCredentials(prefix: "gitea2") + + createAccount.expandEmailSignup() + + // Use the same focusAndType path that OnboardingTests uses — it + // already handles SecureTextField + iOS strong-password panel. + // Under --ui-testing, OrganicOnboardingSecureField defaults to + // visibility=ON (renders as TextField) to dodge the iOS 26 SecureField + // keyboard bug. Query textFields, not secureTextFields. + let usernameField = app.textFields[AccessibilityIdentifiers.Onboarding.usernameField] + let emailField = app.textFields[AccessibilityIdentifiers.Onboarding.emailField] + let passwordField = app.textFields[AccessibilityIdentifiers.Onboarding.passwordField] + let confirmPasswordField = app.textFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField] + + usernameField.waitForExistenceOrFail(timeout: navigationTimeout) + usernameField.focusAndType(creds.username, app: app) + emailField.waitForExistenceOrFail(timeout: navigationTimeout) + emailField.focusAndType(creds.email, app: app) + passwordField.waitForExistenceOrFail(timeout: navigationTimeout) + passwordField.focusAndType(creds.password, app: app) + confirmPasswordField.waitForExistenceOrFail(timeout: navigationTimeout) + confirmPasswordField.focusAndType(creds.password, app: app) + + let createAccountButton = app.descendants(matching: .any) + .matching(identifier: AccessibilityIdentifiers.Onboarding.createAccountButton) + .firstMatch + createAccountButton.waitForExistenceOrFail(timeout: navigationTimeout) + createAccountButton.forceTap() + + // Step 3 — Verify email with the debug fixed code. + let verification = VerificationScreen(app: app) + verification.waitForLoad(timeout: loginTimeout) + verification.enterCode(debugVerificationCode) + // Many onboarding verification screens auto-submit on a 6-digit + // code. If a verify button still exists and a code field is still + // visible, tap it to push past edge cases. + if verification.codeField.waitForExistence(timeout: 1) && verification.verifyButton.exists { + verification.submitCode() + } + + // Step 4 — Skip the home-profile step. The home-profile screen has + // its own Skip button (the shared onboarding skip in the nav bar) + // which routes to the first-task step without making us pick climate + // / appliance fields. + let onboardingSkipButton = app.buttons[AccessibilityIdentifiers.Onboarding.skipButton] + XCTAssertTrue( + onboardingSkipButton.waitForExistence(timeout: loginTimeout), + "Onboarding skip button should exist on the home-profile screen" + ) + // The skip button can briefly be non-hittable during the screen-in + // transition. Use forceTap() to bypass the strict hittable check. + // We confirmed existence above; if the tap doesn't land on the + // intended button the next assertion (Browse All tab) will catch it. + onboardingSkipButton.forceTap() + + // Step 5 — Switch to the "Browse All" tab on the First-Task screen. + // "For You" suggestions can be empty for a fresh residence with no + // home-profile data, so deterministic browsing is required. + // The tab bar is a SwiftUI segmented Picker — its segments are + // exposed as buttons with the segment label, regardless of an + // identifier on the parent. + let browseAllTab = app.buttons["Browse All"] + XCTAssertTrue( + browseAllTab.waitForExistence(timeout: loginTimeout), + "Browse All tab should appear on the first-task screen" + ) + browseAllTab.tap() + + // Step 6 — Pick 3 templates by accessibility identifier prefix. + // The catalog is loaded via GET /api/tasks/templates/grouped/, so + // we need to wait for at least one row to render before tapping. + let templateRowQuery = app.buttons.matching( + NSPredicate(format: "identifier BEGINSWITH %@", + AccessibilityIdentifiers.Onboarding.templateRowPrefix) + ) + + // Wait for the catalog to load. The grouped endpoint returns first + // category expanded by default in the view, so rows should appear + // shortly after Browse All becomes visible. Network call: 10s. + let firstRow = templateRowQuery.element(boundBy: 0) + XCTAssertTrue( + firstRow.waitForExistence(timeout: loginTimeout), + "At least one template row must render on the Browse All tab. " + + "If no rows appear, the catalog endpoint failed — bug repro is invalid." + ) + + // Tap the first 3 visible rows. Some categories may collapse rows + // we never see; we only need at least 1, so the floor is 1 with a + // soft cap of 3. + let rowCount = templateRowQuery.count + let toPick = min(3, rowCount) + XCTAssertGreaterThanOrEqual(toPick, 1, "Expected at least one template row") + for index in 0.. String { return """ Background Task Debug Info: diff --git a/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift b/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift index 7eee095..56c5e45 100644 --- a/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift +++ b/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift @@ -48,6 +48,9 @@ struct AccessibilityIdentifiers { static let addButton = "Residence.AddButton" static let residencesList = "Residence.List" static let residenceCard = "Residence.Card" + /// Prefix for individual residence cells in the list. Suffix with the + /// residence id to address a specific cell (e.g. "Residence.Cell.42"). + static let cellPrefix = "Residence.Cell" static let emptyStateView = "Residence.EmptyState" static let emptyStateButton = "Residence.EmptyState.AddButton" @@ -87,7 +90,15 @@ struct AccessibilityIdentifiers { static let refreshButton = "Task.RefreshButton" static let tasksList = "Task.List" static let taskCard = "Task.Card" + /// Prefix for individual task rows. Suffix with the task id to + /// address a specific row (e.g. "Task.Row.42"). Use `BEGINSWITH` + /// in tests to detect "any task row exists". + static let rowPrefix = "Task.Row" static let emptyStateView = "Task.EmptyState" + /// Label rendered when a residence-detail tasks section has no tasks + /// in any kanban column. Asserted ABSENT after onboarding bulk-create + /// in the gitea#2 regression test. + static let noTasksLabel = "Task.NoTasksLabel" static let kanbanView = "Task.KanbanView" static let overdueColumn = "Task.Column.Overdue" static let upcomingColumn = "Task.Column.Upcoming" @@ -229,8 +240,24 @@ struct AccessibilityIdentifiers { static let taskSelectionCounter = "Onboarding.TaskSelectionCounter" static let addPopularTasksButton = "Onboarding.AddPopularTasksButton" static let addTasksContinueButton = "Onboarding.AddTasksContinueButton" + /// Submit/continue button at the bottom of the First-Task screen. + /// Triggers `POST /api/tasks/bulk/` for the selected templates. + static let submitTasksButton = "Onboarding.SubmitTasksButton" + /// Tab bar control above the task list. The "Browse All" segment is + /// addressed via `app.buttons["Browse All"]` from the segmented + /// picker once this identifier is set. + static let firstTaskTabBar = "Onboarding.FirstTaskTabBar" + /// Tab segment that shows the full template catalog. + /// Tap from a test by addressing the Picker's segment label + /// "Browse All" within the element identified above. + static let browseAllTab = "Onboarding.BrowseAllTab" static let taskCategorySection = "Onboarding.TaskCategorySection" static let taskTemplateRow = "Onboarding.TaskTemplateRow" + /// Prefix for individual template rows on the First-Task screen + /// (Browse All tab). Suffix with the backend template id — + /// e.g. `"Onboarding.TemplateRow.123"`. Tests use `BEGINSWITH` to + /// pick the first N rows deterministically without knowing ids. + static let templateRowPrefix = "Onboarding.TemplateRow" // Subscription Screen static let subscriptionTitle = "Onboarding.SubscriptionTitle" diff --git a/iosApp/iosApp/Helpers/ThemeManager.swift b/iosApp/iosApp/Helpers/ThemeManager.swift index 58cd76f..5bb11b3 100644 --- a/iosApp/iosApp/Helpers/ThemeManager.swift +++ b/iosApp/iosApp/Helpers/ThemeManager.swift @@ -61,7 +61,7 @@ enum ThemeID: String, CaseIterable, Codable { // MARK: - Shared App Group UserDefaults private let appGroupID: String = { - Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev" + Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev" }() private let sharedDefaults: UserDefaults = { guard let defaults = UserDefaults(suiteName: appGroupID) else { diff --git a/iosApp/iosApp/Helpers/WidgetDataManager.swift b/iosApp/iosApp/Helpers/WidgetDataManager.swift index 29e2895..c89847a 100644 --- a/iosApp/iosApp/Helpers/WidgetDataManager.swift +++ b/iosApp/iosApp/Helpers/WidgetDataManager.swift @@ -21,7 +21,7 @@ final class WidgetDataManager { static let cancelledColumn = "cancelled_tasks" private let appGroupIdentifier: String = { - Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev" + Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev" }() private let tasksFileName = "widget_tasks.json" private let actionsFileName = "widget_pending_actions.json" diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 2b50435..bc057c5 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -6,14 +6,8 @@ $(APP_GROUP_IDENTIFIER) BGTaskSchedulerPermittedIdentifiers - com.tt.honeyDue.refresh + com.myhoneydue.honeyDue.refresh - HONEYDUE_IAP_ANNUAL_PRODUCT_ID - com.tt.honeyDue.pro.annual - HONEYDUE_IAP_MONTHLY_PRODUCT_ID - com.tt.honeyDue.pro.monthly - HONEYDUE_GOOGLE_WEB_CLIENT_ID - CFBundleDocumentTypes @@ -40,17 +34,17 @@ + HONEYDUE_GOOGLE_WEB_CLIENT_ID + + HONEYDUE_IAP_ANNUAL_PRODUCT_ID + com.myhoneydue.honeyDue.pro.annual + HONEYDUE_IAP_MONTHLY_PRODUCT_ID + com.myhoneydue.honeyDue.pro.monthly NSAppTransportSecurity NSAllowsLocalNetworking - NSCameraUsageDescription - honeyDue needs camera access to take photos of tasks, documents, and receipts. - NSPhotoLibraryUsageDescription - honeyDue needs photo library access to attach photos to tasks and documents. - NSPhotoLibraryAddUsageDescription - honeyDue needs permission to save photos to your library. UIBackgroundModes remote-notification diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index b2d91aa..1826e42 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -74,6 +74,18 @@ } } }, + "%@, %@, %lld%% match" : { + "comment" : "A row that displays a suggestion with a title, frequency, and relevance percentage.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@, %2$@, %3$lld%% match" + } + } + } + }, "%@, %@%@" : { "comment" : "A button that displays the name of a product and its price.", "isCommentAutoGenerated" : true, @@ -154,6 +166,10 @@ } } }, + "%lld%%" : { + "comment" : "A badge that shows the relevance of a suggestion. The argument is the relevance percentage.", + "isCommentAutoGenerated" : true + }, "•" : { "comment" : "A separator between different pieces of information in a text.", "isCommentAutoGenerated" : true @@ -221,9 +237,6 @@ }, "Add document" : { - }, - "Add Most Popular" : { - }, "Add new property" : { "comment" : "A label displayed as a button in the toolbar.", @@ -17684,10 +17697,6 @@ "comment" : "A button that generates a new share code.", "isCommentAutoGenerated" : true }, - "Generating suggestions..." : { - "comment" : "Text displayed while the app is generating personalized task suggestions.", - "isCommentAutoGenerated" : true - }, "Get notified when someone joins your property" : { }, @@ -17710,16 +17719,8 @@ "comment" : "A label for the back button.", "isCommentAutoGenerated" : true }, - "Good match" : { - "comment" : "A label describing a task's relevance.", - "isCommentAutoGenerated" : true - }, "Google Sign-In Error" : { - }, - "Great match" : { - "comment" : "A label describing a high-relevance task.", - "isCommentAutoGenerated" : true }, "Help improve honeyDue by sharing anonymous usage data" : { @@ -17862,10 +17863,6 @@ }, "No personal data is collected. Analytics are fully anonymous." : { - }, - "No personalized suggestions yet" : { - "comment" : "A message displayed when the user has not yet been personalized.", - "isCommentAutoGenerated" : true }, "No properties yet" : { @@ -25444,6 +25441,10 @@ "comment" : "A button label that allows users to skip the current onboarding step.", "isCommentAutoGenerated" : true }, + "Skip for now" : { + "comment" : "A button label that skips onboarding.", + "isCommentAutoGenerated" : true + }, "Skip for Now" : { }, @@ -30617,10 +30618,6 @@ "comment" : "A button label that says \"Try Again\".", "isCommentAutoGenerated" : true }, - "Try the Browse tab to explore tasks by category,\nor add home details for better suggestions." : { - "comment" : "A description of the benefits of using the", - "isCommentAutoGenerated" : true - }, "Unarchive" : { "comment" : "A button that unarchives a task.", "isCommentAutoGenerated" : true diff --git a/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift b/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift index 19d5c5c..ebe11b4 100644 --- a/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift @@ -366,7 +366,12 @@ struct OnboardingCreateAccountContent: View { } .onChange(of: viewModel.isRegistered) { _, isRegistered in if isRegistered { - // Registration successful - user is authenticated but not verified + // Registration successful — server gave us a token, so we ARE + // authenticated (just not verified yet). Mark the iOS-side auth + // state to match, otherwise OnboardingState.completeOnboarding's + // auth guard silently no-ops at the end of the flow and the + // user gets stuck on the firstTask screen. + AuthenticationManager.shared.login(verified: false) onAccountCreated(false) } } @@ -451,7 +456,13 @@ private struct OrganicOnboardingSecureField: View { @Binding var text: String var isFocused: Bool = false var accessibilityIdentifier: String? = nil - @State private var showPassword = false + // iOS 26 has a known bug where tapping a SwiftUI SecureField with + // `.textContentType(.password)` doesn't reliably bring up the keyboard + // — the strong-password autofill panel steals focus. Under UI tests + // we force the visibility toggle ON, rendering as a plain TextField, + // which has reliable focus behavior. The plaintext isn't a security + // concern in test mode (test creds are throwaway). + @State private var showPassword = UITestRuntime.isEnabled var body: some View { HStack(spacing: 14) { diff --git a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift index c50a707..dc5b86f 100644 --- a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift @@ -158,6 +158,7 @@ struct OnboardingFirstTaskContent: View { OnboardingTaskTabBar(selectedTab: $selectedTab) .padding(.horizontal, OrganicSpacing.comfortable) + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.firstTaskTabBar) switch selectedTab { case .forYou: @@ -384,6 +385,7 @@ struct OnboardingFirstTaskContent: View { .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .naturalShadow(selectedCount > 0 ? .medium : .subtle) } + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.submitTasksButton) .disabled(vm.isSubmitting) .animation(.easeInOut(duration: 0.2), value: selectedCount) } @@ -653,6 +655,7 @@ private struct OnboardingSuggestionRow: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityIdentifier("\(AccessibilityIdentifiers.Onboarding.templateRowPrefix).\(suggestion.template.id)") .accessibilityLabel("\(suggestion.template.title), \(suggestion.template.frequencyDisplay), \(relevancePercent)% match") .accessibilityValue(isSelected ? "selected" : "not selected") } @@ -798,6 +801,7 @@ private struct OnboardingTemplateRow: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityIdentifier("\(AccessibilityIdentifiers.Onboarding.templateRowPrefix).\(template.id)") .accessibilityLabel("\(template.title), \(template.frequencyLabel)") .accessibilityValue(isSelected ? "selected" : "not selected") } diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index 9189d0b..9d6ef02 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -231,6 +231,7 @@ private struct ResidencesContent: View { .padding(.horizontal, 16) } .buttonStyle(OrganicCardButtonStyle()) + .accessibilityIdentifier("\(AccessibilityIdentifiers.Residence.cellPrefix).\(residence.id)") .transition(.asymmetric( insertion: .opacity.combined(with: .move(edge: .bottom)), removal: .opacity diff --git a/iosApp/iosApp/Shared/Utilities/KeychainHelper.swift b/iosApp/iosApp/Shared/Utilities/KeychainHelper.swift index 3562edf..96157c2 100644 --- a/iosApp/iosApp/Shared/Utilities/KeychainHelper.swift +++ b/iosApp/iosApp/Shared/Utilities/KeychainHelper.swift @@ -7,7 +7,7 @@ import ComposeApp final class KeychainHelper: NSObject, KeychainDelegate { static let shared = KeychainHelper() - private let service = "com.tt.honeyDue" + private let service = "com.myhoneydue.honeyDue" func save(key: String, value: String) -> Bool { guard let data = value.data(using: .utf8) else { return false } diff --git a/iosApp/iosApp/Subscription/StoreKitManager.swift b/iosApp/iosApp/Subscription/StoreKitManager.swift index 44b2e83..f5561df 100644 --- a/iosApp/iosApp/Subscription/StoreKitManager.swift +++ b/iosApp/iosApp/Subscription/StoreKitManager.swift @@ -14,8 +14,8 @@ class StoreKitManager: ObservableObject { // Canonical source: SubscriptionProducts in commonMain (Kotlin shared code). // Keep these in sync with SubscriptionProducts.MONTHLY / SubscriptionProducts.ANNUAL. private let fallbackProductIDs = [ - "com.tt.honeyDue.pro.monthly", - "com.tt.honeyDue.pro.annual" + "com.myhoneydue.honeyDue.pro.monthly", + "com.myhoneydue.honeyDue.pro.annual" ] private var configuredProductIDs: [String] { diff --git a/iosApp/iosApp/Subviews/Task/DynamicTaskCard.swift b/iosApp/iosApp/Subviews/Task/DynamicTaskCard.swift index a36d8c6..8cd285c 100644 --- a/iosApp/iosApp/Subviews/Task/DynamicTaskCard.swift +++ b/iosApp/iosApp/Subviews/Task/DynamicTaskCard.swift @@ -122,6 +122,7 @@ struct DynamicTaskCard: View { .cornerRadius(12) .shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2) .simultaneousGesture(TapGesture(), including: .subviews) + .accessibilityIdentifier("\(AccessibilityIdentifiers.Task.rowPrefix).\(task.id)") .sheet(isPresented: $showCompletionHistory) { CompletionHistorySheet( taskTitle: task.title, diff --git a/iosApp/iosApp/Subviews/Task/EmptyTasksView.swift b/iosApp/iosApp/Subviews/Task/EmptyTasksView.swift index 1c32104..b44a2d2 100644 --- a/iosApp/iosApp/Subviews/Task/EmptyTasksView.swift +++ b/iosApp/iosApp/Subviews/Task/EmptyTasksView.swift @@ -22,6 +22,8 @@ struct EmptyTasksView: View { .background(Color.appBackgroundSecondary) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .naturalShadow(.subtle) + .accessibilityElement(children: .combine) + .accessibilityIdentifier(AccessibilityIdentifiers.Task.noTasksLabel) } } diff --git a/iosApp/iosApp/Task/TaskViewModel.swift b/iosApp/iosApp/Task/TaskViewModel.swift index d163104..0bc0850 100644 --- a/iosApp/iosApp/Task/TaskViewModel.swift +++ b/iosApp/iosApp/Task/TaskViewModel.swift @@ -42,6 +42,12 @@ class TaskViewModel: ObservableObject { private let dataManager: DataManagerObservable // MARK: - Initialization + /// Single source of truth = DataManager._allTasks. When this VM is + /// residence-scoped (currentResidenceId set), filter in-memory by + /// residence id. Eliminates the gitea#2 race window where the + /// per-residence cache slot could be empty while _allTasks was + /// populated. The per-residence cache is gone (cec521b). + /// /// - Parameter dataManager: Observable cache the VM subscribes to. /// Defaults to the shared singleton. Tests inject a fixture-backed /// instance so populated-state snapshots render real data. @@ -50,35 +56,26 @@ class TaskViewModel: ObservableObject { // Seed from current cache so snapshot tests/previews render // populated state without waiting for Combine's async dispatch. + // The seed path mirrors the steady-state filter below — if this + // VM is residence-scoped at construction time the seed has to + // pre-filter too, but currentResidenceId is set after init via + // setResidenceFilter(...), so seeding the unfiltered list is fine. self.tasksResponse = dataManager.allTasks - // Observe injected DataManagerObservable for all tasks data + // Observe injected DataManagerObservable for all tasks data. dataManager.$allTasks .receive(on: DispatchQueue.main) .sink { [weak self] allTasks in - // Skip DataManager updates during completion animation to prevent - // the task from being moved out of its column before the animation finishes - guard self?.isAnimatingCompletion != true else { return } - // Only update if we're showing all tasks (no residence filter) - if self?.currentResidenceId == nil { - self?.tasksResponse = allTasks - if allTasks != nil { - self?.isLoadingTasks = false - } - } - } - .store(in: &cancellables) + guard let self else { return } + guard !self.isAnimatingCompletion else { return } - // Observe tasks by residence - dataManager.$tasksByResidence - .receive(on: DispatchQueue.main) - .sink { [weak self] tasksByResidence in - guard self?.isAnimatingCompletion != true else { return } - // Only update if we're filtering by residence - if let resId = self?.currentResidenceId, - let tasks = tasksByResidence[resId] { - self?.tasksResponse = tasks - self?.isLoadingTasks = false + if let allTasks { + if let resId = self.currentResidenceId { + self.tasksResponse = self.filterTasks(allTasks, residenceId: resId) + } else { + self.tasksResponse = allTasks + } + self.isLoadingTasks = false } } .store(in: &cancellables) @@ -392,6 +389,28 @@ class TaskViewModel: ObservableObject { } } + /// Filter the all-tasks kanban down to a single residence in-memory. + /// Mirrors `DataManager.getTasksForResidence` on the Kotlin side. + private func filterTasks(_ response: TaskColumnsResponse, residenceId: Int32) -> TaskColumnsResponse { + let filteredColumns = response.columns.map { column -> TaskColumn in + let filteredTasks = column.tasks.filter { Int32($0.residenceId) == residenceId } + return TaskColumn( + name: column.name, + displayName: column.displayName, + buttonTypes: column.buttonTypes, + icons: column.icons, + color: column.color, + tasks: filteredTasks, + count: Int32(filteredTasks.count) + ) + } + return TaskColumnsResponse( + columns: filteredColumns, + daysThreshold: response.daysThreshold, + residenceId: String(residenceId) + ) + } + /// Updates a task in the kanban board by moving it to the correct column based on kanban_column func updateTaskInKanban(_ updatedTask: TaskResponse) { guard let currentResponse = tasksResponse else { return }