Remove summary from list responses, calculate client-side

- Remove summary field from MyResidencesResponse and TaskColumnsResponse
- Update HomeScreen and ResidencesScreen to observe DataManager.totalSummary
- Load tasks when residence views appear to ensure accurate summary
- Add pull-to-refresh for tasks on ResidencesScreen
- Update iOS views to use client-side calculated summary

Summary is now calculated via refreshSummaryFromKanban() from cached
kanban data. This ensures summary is always up-to-date after CRUD
operations and when residence views are shown.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-16 17:06:48 -06:00
parent 6dfc4ee57c
commit fbe45da9ff
8 changed files with 52 additions and 40 deletions

View File

@@ -285,20 +285,17 @@ object DataManager {
fun setMyResidences(response: MyResidencesResponse) { fun setMyResidences(response: MyResidencesResponse) {
_myResidences.value = response _myResidences.value = response
// Also update totalSummary from myResidences response
_totalSummary.value = response.summary
myResidencesCacheTime = currentTimeMs() myResidencesCacheTime = currentTimeMs()
summaryCacheTime = currentTimeMs() summaryCacheTime = currentTimeMs()
updateLastSyncTime() updateLastSyncTime()
// Calculate summary from cached kanban data (API no longer returns summary stats)
// This ensures summary is always up-to-date when residence view is shown
refreshSummaryFromKanban()
persistToDisk() persistToDisk()
} }
fun setTotalSummary(summary: TotalSummary) { fun setTotalSummary(summary: TotalSummary) {
_totalSummary.value = summary _totalSummary.value = summary
// Also update the summary in myResidences if it exists
_myResidences.value?.let { current ->
_myResidences.value = current.copy(summary = summary)
}
summaryCacheTime = currentTimeMs() summaryCacheTime = currentTimeMs()
persistToDisk() persistToDisk()
} }
@@ -356,10 +353,6 @@ object DataManager {
fun refreshSummaryFromKanban() { fun refreshSummaryFromKanban() {
val calculatedSummary = calculateSummaryFromKanban() val calculatedSummary = calculateSummaryFromKanban()
_totalSummary.value = calculatedSummary _totalSummary.value = calculatedSummary
// Also update myResidences summary if present
_myResidences.value?.let { current ->
_myResidences.value = current.copy(summary = calculatedSummary)
}
} }
fun setResidenceSummary(residenceId: Int, summary: ResidenceSummaryResponse) { fun setResidenceSummary(residenceId: Int, summary: ResidenceSummaryResponse) {
@@ -431,8 +424,7 @@ object DataManager {
return TaskColumnsResponse( return TaskColumnsResponse(
columns = filteredColumns, columns = filteredColumns,
daysThreshold = allTasksData.daysThreshold, daysThreshold = allTasksData.daysThreshold,
residenceId = residenceId.toString(), residenceId = residenceId.toString()
summary = null // Summary is global; residence-specific not available client-side
) )
} }

View File

@@ -178,13 +178,13 @@ data class TaskColumn(
/** /**
* Kanban board response matching Go API KanbanBoardResponse * Kanban board response matching Go API KanbanBoardResponse
* NOTE: Summary statistics are calculated client-side from kanban data
*/ */
@Serializable @Serializable
data class TaskColumnsResponse( data class TaskColumnsResponse(
val columns: List<TaskColumn>, val columns: List<TaskColumn>,
@SerialName("days_threshold") val daysThreshold: Int, @SerialName("days_threshold") val daysThreshold: Int,
@SerialName("residence_id") val residenceId: String, @SerialName("residence_id") val residenceId: String
val summary: TotalSummary? = null
) )
/** /**

View File

@@ -200,12 +200,11 @@ data class WithSummaryResponse<T>(
/** /**
* My residences response - list of user's residences * My residences response - list of user's residences
* Go API returns array directly, this wraps for consistency * NOTE: Summary statistics are calculated client-side from kanban data
*/ */
@Serializable @Serializable
data class MyResidencesResponse( data class MyResidencesResponse(
val residences: List<ResidenceResponse>, val residences: List<ResidenceResponse>
val summary: TotalSummary = TotalSummary()
) )
/** /**

View File

@@ -18,7 +18,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.theme.AppRadius import com.example.casera.ui.theme.AppRadius
import com.example.casera.viewmodel.ResidenceViewModel import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.viewmodel.TaskViewModel
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import com.example.casera.data.DataManager
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -28,12 +30,16 @@ fun HomeScreen(
onNavigateToResidences: () -> Unit, onNavigateToResidences: () -> Unit,
onNavigateToTasks: () -> Unit, onNavigateToTasks: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() } viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
taskViewModel: TaskViewModel = viewModel { TaskViewModel() }
) { ) {
val summaryState by viewModel.myResidencesState.collectAsState() val summaryState by viewModel.myResidencesState.collectAsState()
val totalSummary by DataManager.totalSummary.collectAsState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loadMyResidences() viewModel.loadMyResidences()
// Also load tasks to ensure summary can be calculated from kanban data
taskViewModel.loadTasks()
} }
// Handle errors for loading summary // Handle errors for loading summary
@@ -159,7 +165,7 @@ fun HomeScreen(
color = MaterialTheme.colorScheme.outlineVariant color = MaterialTheme.colorScheme.outlineVariant
) )
StatItem( StatItem(
value = "${summary.summary.totalTasks}", value = "${totalSummary?.totalTasks ?: 0}",
label = stringResource(Res.string.home_total_tasks), label = stringResource(Res.string.home_total_tasks),
color = Color(0xFF8B5CF6) color = Color(0xFF8B5CF6)
) )
@@ -170,7 +176,7 @@ fun HomeScreen(
color = MaterialTheme.colorScheme.outlineVariant color = MaterialTheme.colorScheme.outlineVariant
) )
StatItem( StatItem(
value = "${summary.summary.totalPending}", value = "${totalSummary?.totalPending ?: 0}",
label = stringResource(Res.string.home_pending), label = stringResource(Res.string.home_pending),
color = Color(0xFFF59E0B) color = Color(0xFFF59E0B)
) )

View File

@@ -29,12 +29,14 @@ import com.example.casera.ui.components.JoinResidenceDialog
import com.example.casera.ui.components.common.StatItem import com.example.casera.ui.components.common.StatItem
import com.example.casera.ui.components.residence.TaskStatChip import com.example.casera.ui.components.residence.TaskStatChip
import com.example.casera.viewmodel.ResidenceViewModel import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.viewmodel.TaskViewModel
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import com.example.casera.utils.SubscriptionHelper import com.example.casera.utils.SubscriptionHelper
import com.example.casera.ui.subscription.UpgradePromptDialog import com.example.casera.ui.subscription.UpgradePromptDialog
import com.example.casera.cache.SubscriptionCache import com.example.casera.cache.SubscriptionCache
import com.example.casera.analytics.PostHogAnalytics import com.example.casera.analytics.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents import com.example.casera.analytics.AnalyticsEvents
import com.example.casera.data.DataManager
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -46,9 +48,11 @@ fun ResidencesScreen(
onLogout: () -> Unit, onLogout: () -> Unit,
onNavigateToProfile: () -> Unit = {}, onNavigateToProfile: () -> Unit = {},
shouldRefresh: Boolean = false, shouldRefresh: Boolean = false,
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() } viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
taskViewModel: TaskViewModel = viewModel { TaskViewModel() }
) { ) {
val myResidencesState by viewModel.myResidencesState.collectAsState() val myResidencesState by viewModel.myResidencesState.collectAsState()
val totalSummary by DataManager.totalSummary.collectAsState()
var showJoinDialog by remember { mutableStateOf(false) } var showJoinDialog by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
var showUpgradePrompt by remember { mutableStateOf(false) } var showUpgradePrompt by remember { mutableStateOf(false) }
@@ -65,16 +69,19 @@ fun ResidencesScreen(
return Pair(check.allowed, check.triggerKey) return Pair(check.allowed, check.triggerKey)
} }
// Track screen view // Track screen view and load data
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
PostHogAnalytics.screen(AnalyticsEvents.RESIDENCE_SCREEN_SHOWN) PostHogAnalytics.screen(AnalyticsEvents.RESIDENCE_SCREEN_SHOWN)
viewModel.loadMyResidences() viewModel.loadMyResidences()
// Also load tasks to ensure summary can be calculated from kanban data
taskViewModel.loadTasks()
} }
// Refresh when shouldRefresh flag changes // Refresh when shouldRefresh flag changes
LaunchedEffect(shouldRefresh) { LaunchedEffect(shouldRefresh) {
if (shouldRefresh) { if (shouldRefresh) {
viewModel.loadMyResidences(forceRefresh = true) viewModel.loadMyResidences(forceRefresh = true)
taskViewModel.loadTasks(forceRefresh = true)
} }
} }
@@ -327,7 +334,8 @@ fun ResidencesScreen(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
onRefresh = { onRefresh = {
isRefreshing = true isRefreshing = true
viewModel.loadMyResidences() viewModel.loadMyResidences(forceRefresh = true)
taskViewModel.loadTasks(forceRefresh = true)
}, },
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -383,12 +391,12 @@ fun ResidencesScreen(
) { ) {
StatItem( StatItem(
icon = Icons.Default.Home, icon = Icons.Default.Home,
value = "${response.summary.totalResidences}", value = "${totalSummary?.totalResidences ?: response.residences.size}",
label = stringResource(Res.string.home_properties) label = stringResource(Res.string.home_properties)
) )
StatItem( StatItem(
icon = Icons.Default.Assignment, icon = Icons.Default.Assignment,
value = "${response.summary.totalTasks}", value = "${totalSummary?.totalTasks ?: 0}",
label = stringResource(Res.string.home_total_tasks) label = stringResource(Res.string.home_total_tasks)
) )
} }
@@ -403,18 +411,18 @@ fun ResidencesScreen(
) { ) {
StatItem( StatItem(
icon = Icons.Default.Warning, icon = Icons.Default.Warning,
value = "${response.summary.totalOverdue}", value = "${totalSummary?.totalOverdue ?: 0}",
label = stringResource(Res.string.home_overdue), label = stringResource(Res.string.home_overdue),
valueColor = if (response.summary.totalOverdue > 0) MaterialTheme.colorScheme.error else null valueColor = if ((totalSummary?.totalOverdue ?: 0) > 0) MaterialTheme.colorScheme.error else null
) )
StatItem( StatItem(
icon = Icons.Default.CalendarToday, icon = Icons.Default.CalendarToday,
value = "${response.summary.tasksDueNextWeek}", value = "${totalSummary?.tasksDueNextWeek ?: 0}",
label = stringResource(Res.string.home_due_this_week) label = stringResource(Res.string.home_due_this_week)
) )
StatItem( StatItem(
icon = Icons.Default.Event, icon = Icons.Default.Event,
value = "${response.summary.tasksDueNextMonth}", value = "${totalSummary?.tasksDueNextMonth ?: 0}",
label = stringResource(Res.string.home_next_30_days) label = stringResource(Res.string.home_next_30_days)
) )
} }

View File

@@ -93,10 +93,7 @@ final class WidgetActionProcessor {
let data = success.data { let data = success.data {
// Update widget with fresh data // Update widget with fresh data
WidgetDataManager.shared.saveTasks(from: data) WidgetDataManager.shared.saveTasks(from: data)
// Update summary from response (no extra API call needed) // Summary is calculated client-side by DataManager.setAllTasks() -> refreshSummaryFromKanban()
if let summary = data.summary {
DataManager.shared.setTotalSummary(summary: summary)
}
} }
} catch { } catch {
print("WidgetActionProcessor: Error refreshing tasks: \(error)") print("WidgetActionProcessor: Error refreshing tasks: \(error)")

View File

@@ -3,12 +3,14 @@ import ComposeApp
struct ResidencesListView: View { struct ResidencesListView: View {
@StateObject private var viewModel = ResidenceViewModel() @StateObject private var viewModel = ResidenceViewModel()
@StateObject private var taskViewModel = TaskViewModel()
@State private var showingAddResidence = false @State private var showingAddResidence = false
@State private var showingJoinResidence = false @State private var showingJoinResidence = false
@State private var showingUpgradePrompt = false @State private var showingUpgradePrompt = false
@State private var showingSettings = false @State private var showingSettings = false
@StateObject private var authManager = AuthenticationManager.shared @StateObject private var authManager = AuthenticationManager.shared
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@Environment(\.scenePhase) private var scenePhase
var body: some View { var body: some View {
ZStack { ZStack {
@@ -22,7 +24,7 @@ struct ResidencesListView: View {
errorMessage: viewModel.errorMessage, errorMessage: viewModel.errorMessage,
content: { residences in content: { residences in
ResidencesContent( ResidencesContent(
summary: viewModel.totalSummary ?? response.summary, summary: viewModel.totalSummary ?? TotalSummary(totalResidences: Int32(residences.count), totalTasks: 0, totalOverdue: 0, totalDueSoon: 0, totalPending: 0, tasksDueNextWeek: 0, tasksDueNextMonth: 0),
residences: residences residences: residences
) )
}, },
@@ -114,19 +116,30 @@ struct ResidencesListView: View {
PostHogAnalytics.shared.screen(AnalyticsEvents.residenceScreenShown) PostHogAnalytics.shared.screen(AnalyticsEvents.residenceScreenShown)
if authManager.isAuthenticated { if authManager.isAuthenticated {
viewModel.loadMyResidences() viewModel.loadMyResidences()
// Also load tasks to populate summary stats
taskViewModel.loadTasks()
}
}
.onChange(of: scenePhase) { newPhase in
// Refresh data when app comes back from background
if newPhase == .active && authManager.isAuthenticated {
viewModel.loadMyResidences(forceRefresh: true)
taskViewModel.loadTasks(forceRefresh: true)
} }
} }
.fullScreenCover(isPresented: $authManager.isAuthenticated.negated) { .fullScreenCover(isPresented: $authManager.isAuthenticated.negated) {
LoginView(onLoginSuccess: { LoginView(onLoginSuccess: {
authManager.isAuthenticated = true authManager.isAuthenticated = true
viewModel.loadMyResidences() viewModel.loadMyResidences()
taskViewModel.loadTasks()
}) })
.interactiveDismissDisabled() .interactiveDismissDisabled()
} }
.onChange(of: authManager.isAuthenticated) { isAuth in .onChange(of: authManager.isAuthenticated) { isAuth in
if isAuth { if isAuth {
// User just logged in or registered - load their residences // User just logged in or registered - load their residences and tasks
viewModel.loadMyResidences() viewModel.loadMyResidences()
taskViewModel.loadTasks()
} else { } else {
// User logged out - clear data // User logged out - clear data
viewModel.myResidences = nil viewModel.myResidences = nil

View File

@@ -79,16 +79,13 @@ struct iOSApp: App {
if WidgetDataManager.shared.areTasksDirty() { if WidgetDataManager.shared.areTasksDirty() {
WidgetDataManager.shared.clearDirtyFlag() WidgetDataManager.shared.clearDirtyFlag()
Task { Task {
// Refresh tasks - response includes summary for dashboard stats // Refresh tasks - summary is calculated client-side from kanban data
let result = try? await APILayer.shared.getTasks(forceRefresh: true) let result = try? await APILayer.shared.getTasks(forceRefresh: true)
if let success = result as? ApiResultSuccess<TaskColumnsResponse>, if let success = result as? ApiResultSuccess<TaskColumnsResponse>,
let data = success.data { let data = success.data {
// Update widget cache // Update widget cache
WidgetDataManager.shared.saveTasks(from: data) WidgetDataManager.shared.saveTasks(from: data)
// Update summary from response (no extra API call needed) // Summary is calculated by DataManager.setAllTasks() -> refreshSummaryFromKanban()
if let summary = data.summary {
DataManager.shared.setTotalSummary(summary: summary)
}
} }
} }
} }