Wire onboarding task suggestions to backend, delete hardcoded catalog

Both "For You" and "Browse All" tabs are now fully server-driven on
iOS and Android. No on-device task list, no client-side scoring rules.
When the API fails the screen shows error + Retry + Skip so onboarding
can still complete on a flaky network.

Shared (KMM)
- TaskCreateRequest + TaskResponse carry templateId
- New BulkCreateTasksRequest/Response, TaskApi.bulkCreateTasks,
  APILayer.bulkCreateTasks (updates DataManager + TotalSummary)
- OnboardingViewModel: templatesGroupedState + loadTemplatesGrouped;
  createTasks(residenceId, requests) posts once via the bulk path
- Deleted regional-template plumbing: APILayer.getRegionalTemplates,
  OnboardingViewModel.loadRegionalTemplates, TaskTemplateApi.
  getTemplatesByRegion, TaskTemplate.regionId/regionName
- 5 new AnalyticsEvents constants for the onboarding funnel

Android (Compose)
- OnboardingFirstTaskContent rewritten against the server catalog;
  ~70 lines of hardcoded taskCategories gone. Loading / Error / Empty
  panes with Retry + Skip buttons. Category icons derived from name
  keywords, colours from a 5-value palette keyed by category id
- Browse selection carries template.id into the bulk request so
  task_template_id is populated server-side

iOS (SwiftUI)
- New OnboardingTasksViewModel (@MainActor ObservableObject) wrapping
  APILayer.shared for suggestions / grouped / bulk-submit with
  loading + error state (mirrors the TaskViewModel.swift pattern)
- OnboardingFirstTaskView rewritten: buildForYouSuggestions (130 lines)
  and fallbackCategories (68 lines) deleted; both tabs show the same
  error+skip UX as Android; ForYouSuggestion/SuggestionRelevance gone
- 5 new AnalyticsEvent cases with identical PostHog event names to
  the Kotlin constants so cross-platform funnels join cleanly
- Existing TaskCreateRequest / TaskResponse call sites in TaskCard,
  TasksSection, TaskFormView updated for the new templateId parameter

Docs
- CLAUDE.md gains an "Onboarding task suggestions (server-driven)"
  subsection covering the data flow, key files on both platforms,
  and the KotlinInt(int: template.id) wrapping requirement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-14 15:25:01 -05:00
parent d545fd463c
commit 9ececfa48a
16 changed files with 1399 additions and 1255 deletions

View File

@@ -36,6 +36,20 @@ object AnalyticsEvents {
const val NEW_TASK_SCREEN_SHOWN = "new_task_screen_shown"
const val TASK_CREATED = "task_created"
// Onboarding — First Task screen funnel
// Fired by both iOS and Android with identical event names so the
// PostHog funnel is cross-platform. Properties documented next to each.
// ONBOARDING_SUGGESTIONS_LOADED: {"count": Int, "profile_completeness": Double}
// ONBOARDING_SUGGESTION_ACCEPTED: {"template_id": Int, "relevance_score": Double}
// ONBOARDING_BROWSE_TEMPLATE_ACCEPTED: {"template_id": Int, "category": String?}
// ONBOARDING_TASKS_CREATED: {"count": Int}
// ONBOARDING_TASK_STEP_SKIPPED: {"reason": "network_error" | "user_skip"}
const val ONBOARDING_SUGGESTIONS_LOADED = "onboarding_suggestions_loaded"
const val ONBOARDING_SUGGESTION_ACCEPTED = "onboarding_suggestion_accepted"
const val ONBOARDING_BROWSE_TEMPLATE_ACCEPTED = "onboarding_browse_template_accepted"
const val ONBOARDING_TASKS_CREATED = "onboarding_tasks_created"
const val ONBOARDING_TASK_STEP_SKIPPED = "onboarding_task_step_skipped"
// Contractor
const val CONTRACTOR_SCREEN_SHOWN = "contractor_screen_shown"
const val NEW_CONTRACTOR_SCREEN_SHOWN = "new_contractor_screen_shown"

View File

@@ -53,6 +53,9 @@ data class TaskResponse(
@SerialName("is_cancelled") val isCancelled: Boolean = false,
@SerialName("is_archived") val isArchived: Boolean = false,
@SerialName("parent_task_id") val parentTaskId: Int? = null,
// Backlink to the TaskTemplate this task was spawned from (onboarding
// suggestion or browse catalog). Null for user-created custom tasks.
@SerialName("template_id") val templateId: Int? = null,
@SerialName("completion_count") val completionCount: Int = 0,
@SerialName("kanban_column") val kanbanColumn: String? = null, // Which kanban column this task belongs to
// Note: Go API does not return completions inline with TaskResponse.
@@ -133,7 +136,33 @@ data class TaskCreateRequest(
@SerialName("assigned_to_id") val assignedToId: Int? = null,
@SerialName("due_date") val dueDate: String? = null,
@SerialName("estimated_cost") val estimatedCost: Double? = null,
@SerialName("contractor_id") val contractorId: Int? = null
@SerialName("contractor_id") val contractorId: Int? = null,
// Set when the task is spawned from a TaskTemplate (onboarding
// suggestion or browse catalog). Null for free-form custom tasks.
@SerialName("template_id") val templateId: Int? = null
)
/**
* Bulk create request matching Go API BulkCreateTasksRequest.
* Used by onboarding to insert 1-50 tasks atomically in a single
* transaction. The server forces every entry's residence_id to the
* top-level value, so any mismatch in the list is silently corrected.
*/
@Serializable
data class BulkCreateTasksRequest(
@SerialName("residence_id") val residenceId: Int,
val tasks: List<TaskCreateRequest>
)
/**
* Bulk create response matching Go API BulkCreateTasksResponse.
* All [tasks] are created-or-none — partial state never reaches the client.
*/
@Serializable
data class BulkCreateTasksResponse(
val tasks: List<TaskResponse>,
val summary: TotalSummary,
@SerialName("created_count") val createdCount: Int
)
/**

View File

@@ -20,9 +20,7 @@ data class TaskTemplate(
@SerialName("icon_android") val iconAndroid: String = "",
val tags: List<String> = emptyList(),
@SerialName("display_order") val displayOrder: Int = 0,
@SerialName("is_active") val isActive: Boolean = true,
@SerialName("region_id") val regionId: Int? = null,
@SerialName("region_name") val regionName: String? = null
@SerialName("is_active") val isActive: Boolean = true
) {
/**
* Human-readable frequency display

View File

@@ -638,6 +638,23 @@ 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.
*/
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
}
suspend fun updateTask(id: Int, request: TaskCreateRequest): ApiResult<TaskResponse> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
val result = taskApi.updateTask(token, id, request)
@@ -1200,15 +1217,6 @@ object APILayer {
} ?: ApiResult.Error("Task template not found")
}
/**
* Get task templates filtered by climate region.
* Accepts either a state abbreviation or ZIP code — backend resolves to climate zone.
* This calls the API directly since regional templates are not cached in seeded data.
*/
suspend fun getRegionalTemplates(state: String? = null, zip: String? = null): ApiResult<List<TaskTemplate>> {
return taskTemplateApi.getTemplatesByRegion(state = state, zip = zip)
}
/**
* Get personalized task suggestions for a residence based on its home profile.
*/

View File

@@ -66,6 +66,31 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
}
}
/**
* Atomically creates 1-50 tasks in a single transaction. Used by
* onboarding and anywhere else that needs "all or nothing" task
* creation. The server overrides every entry's residence_id with the
* top-level request.residenceId.
*/
suspend fun bulkCreateTasks(token: String, request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
return try {
val response = client.post("$baseUrl/tasks/bulk/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
return try {
val response = client.put("$baseUrl/tasks/$id/") {

View File

@@ -85,27 +85,6 @@ class TaskTemplateApi(private val client: HttpClient = ApiClient.httpClient) {
}
}
/**
* Get templates filtered by climate region.
* Accepts either a state abbreviation or ZIP code — backend resolves to climate zone.
*/
suspend fun getTemplatesByRegion(state: String? = null, zip: String? = null): ApiResult<List<TaskTemplate>> {
return try {
val response = client.get("$baseUrl/tasks/templates/by-region/") {
state?.let { parameter("state", it) }
zip?.let { parameter("zip", it) }
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch regional templates", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Get personalized task suggestions for a residence based on its home profile.
* Requires authentication.

View File

@@ -4,12 +4,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.models.AuthResponse
import com.tt.honeyDue.models.BulkCreateTasksRequest
import com.tt.honeyDue.models.LoginRequest
import com.tt.honeyDue.models.RegisterRequest
import com.tt.honeyDue.models.ResidenceCreateRequest
import com.tt.honeyDue.models.TaskCreateRequest
import com.tt.honeyDue.models.TaskSuggestionsResponse
import com.tt.honeyDue.models.TaskTemplate
import com.tt.honeyDue.models.TaskTemplatesGroupedResponse
import com.tt.honeyDue.models.VerifyEmailRequest
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.network.APILayer
@@ -80,15 +81,17 @@ class OnboardingViewModel : ViewModel() {
private val _joinResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
val joinResidenceState: StateFlow<ApiResult<Unit>> = _joinResidenceState
// Task creation state
// Task creation state (bulk create)
private val _createTasksState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
val createTasksState: StateFlow<ApiResult<Unit>> = _createTasksState
// Regional templates state
private val _regionalTemplates = MutableStateFlow<ApiResult<List<TaskTemplate>>>(ApiResult.Idle)
val regionalTemplates: StateFlow<ApiResult<List<TaskTemplate>>> = _regionalTemplates
// Grouped templates for the Browse tab on the First-Task screen
private val _templatesGroupedState = MutableStateFlow<ApiResult<TaskTemplatesGroupedResponse>>(ApiResult.Idle)
val templatesGroupedState: StateFlow<ApiResult<TaskTemplatesGroupedResponse>> = _templatesGroupedState
// ZIP code entered during location step (persisted on residence)
// ZIP code entered during onboarding (persisted on residence). Still
// collected so the suggestion service can factor in the user's climate
// zone as its 15th scoring condition.
private val _postalCode = MutableStateFlow("")
val postalCode: StateFlow<String> = _postalCode
@@ -396,9 +399,15 @@ class OnboardingViewModel : ViewModel() {
}
/**
* Create selected tasks during onboarding
* Create the user's selected tasks in a single atomic bulk request.
* The server runs the inserts inside one transaction, so either every
* entry lands or none do — no risk of a half-populated kanban board
* if the network flakes mid-batch.
*
* [residenceId] overrides every entry's residence_id on the server side;
* pass the just-created residence's ID.
*/
fun createTasks(taskRequests: List<TaskCreateRequest>) {
fun createTasks(residenceId: Int, taskRequests: List<TaskCreateRequest>) {
viewModelScope.launch {
if (taskRequests.isEmpty()) {
_createTasksState.value = ApiResult.Success(Unit)
@@ -407,31 +416,28 @@ class OnboardingViewModel : ViewModel() {
_createTasksState.value = ApiResult.Loading
var successCount = 0
for (request in taskRequests) {
val result = APILayer.createTask(request)
if (result is ApiResult.Success) {
successCount++
}
}
_createTasksState.value = if (successCount > 0) {
ApiResult.Success(Unit)
} else {
ApiResult.Error("Failed to create tasks")
val request = BulkCreateTasksRequest(
residenceId = residenceId,
tasks = taskRequests
)
_createTasksState.value = when (val result = APILayer.bulkCreateTasks(request)) {
is ApiResult.Success -> ApiResult.Success(Unit)
is ApiResult.Error -> ApiResult.Error(result.message, result.code)
is ApiResult.Loading -> ApiResult.Loading
is ApiResult.Idle -> ApiResult.Idle
}
}
}
/**
* Load regional templates by ZIP code (backend resolves ZIP → state → climate zone).
* Also stores the ZIP code for later use when creating the residence.
* Load the flat template catalog grouped by category. Feeds the Browse
* tab on the First-Task screen; no caching special-case because
* APILayer.getTaskTemplatesGrouped already reads from DataManager first.
*/
fun loadRegionalTemplates(zip: String) {
_postalCode.value = zip
fun loadTemplatesGrouped(forceRefresh: Boolean = false) {
viewModelScope.launch {
_regionalTemplates.value = ApiResult.Loading
_regionalTemplates.value = APILayer.getRegionalTemplates(zip = zip)
_templatesGroupedState.value = ApiResult.Loading
_templatesGroupedState.value = APILayer.getTaskTemplatesGrouped(forceRefresh = forceRefresh)
}
}
@@ -456,7 +462,7 @@ class OnboardingViewModel : ViewModel() {
_createResidenceState.value = ApiResult.Idle
_joinResidenceState.value = ApiResult.Idle
_createTasksState.value = ApiResult.Idle
_regionalTemplates.value = ApiResult.Idle
_templatesGroupedState.value = ApiResult.Idle
_postalCode.value = ""
_heatingType.value = null
_coolingType.value = null