From 4daaa1f7d889af9ef11e0b2aa1bd04385c8afa5e Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 23 Dec 2025 20:26:52 -0600 Subject: [PATCH] Consolidate task metrics to single source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add API column name constants to WidgetDataManager (overdueColumn, dueWithin7DaysColumn, due8To30DaysColumn, etc.) - Update DataManagerObservable to use WidgetDataManager column constants - Remove duplicate ResidenceTaskStats struct, use TaskMetrics everywhere - Delete TaskStatsCalculator.swift (consolidated into WidgetDataManager) - Rename confusing flags: isUpcoming → isDueWithin7Days, isLater → isDue8To30Days - Add comprehensive unit tests for TaskMetrics and WidgetTask 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- iosApp/Casera/MyCrib.swift | 134 ++++-- iosApp/CaseraTests/TaskMetricsTests.swift | 394 ++++++++++++++++++ iosApp/iosApp.xcodeproj/project.pbxproj | 1 - .../iosApp/Data/DataManagerObservable.swift | 75 ++++ iosApp/iosApp/Helpers/WidgetDataManager.swift | 90 ++-- .../iosApp/Residence/ResidencesListView.swift | 66 +-- .../iosApp/Shared/TaskStatsCalculator.swift | 97 ----- .../Subviews/Residence/ResidenceCard.swift | 20 +- 8 files changed, 637 insertions(+), 240 deletions(-) create mode 100644 iosApp/CaseraTests/TaskMetricsTests.swift delete mode 100644 iosApp/iosApp/Shared/TaskStatsCalculator.swift diff --git a/iosApp/Casera/MyCrib.swift b/iosApp/Casera/MyCrib.swift index 5f50178..7f57cc2 100644 --- a/iosApp/Casera/MyCrib.swift +++ b/iosApp/Casera/MyCrib.swift @@ -69,6 +69,8 @@ class CacheManager { let category: String? let residenceName: String? let isOverdue: Bool + let isDueWithin7Days: Bool + let isDue8To30Days: Bool enum CodingKeys: String, CodingKey { case id, title, description, priority, category @@ -76,6 +78,8 @@ class CacheManager { case dueDate = "due_date" case residenceName = "residence_name" case isOverdue = "is_overdue" + case isDueWithin7Days = "is_due_within_7_days" + case isDue8To30Days = "is_due_8_to_30_days" } /// Whether this task is pending completion (tapped on widget, waiting for sync) @@ -203,27 +207,19 @@ struct SimpleEntry: TimelineEntry { upcomingTasks.first } - /// Computed task stats using shared TaskStatsCalculator - /// Uses exclusive buckets: overdue | next 7 days | next 30 days (8-30) - private var calculatedStats: TaskStats { - let dueDates = upcomingTasks.map { $0.dueDate } - return TaskStatsCalculator.calculate(from: dueDates) - } - - /// Overdue tasks count - uses the isOverdue flag from kanban categorization - /// which correctly excludes in-progress tasks + /// Overdue tasks count - uses isOverdue flag from API column var overdueCount: Int { upcomingTasks.filter { $0.isOverdue }.count } - /// Tasks due within the next 7 days (exclusive of overdue) + /// Tasks due within the next 7 days var dueNext7DaysCount: Int { - calculatedStats.next7DaysCount + upcomingTasks.filter { $0.isDueWithin7Days }.count } - /// Tasks due within next 30 days (days 8-30, exclusive of next 7 days) + /// Tasks due within 8-30 days var dueNext30DaysCount: Int { - calculatedStats.next30DaysCount + upcomingTasks.filter { $0.isDue8To30Days }.count } } @@ -785,7 +781,9 @@ struct Casera: Widget { dueDate: "2024-12-15", category: "plumbing", residenceName: "Home", - isOverdue: false + isOverdue: false, + isDueWithin7Days: true, + isDue8To30Days: false ), CacheManager.CustomTask( id: 2, @@ -796,7 +794,9 @@ struct Casera: Widget { dueDate: "2024-12-20", category: "painting", residenceName: "Home", - isOverdue: false + isOverdue: false, + isDueWithin7Days: true, + isDue8To30Days: false ) ], isInteractive: true @@ -816,7 +816,9 @@ struct Casera: Widget { dueDate: "2024-12-15", category: "plumbing", residenceName: "Home", - isOverdue: false + isOverdue: false, + isDueWithin7Days: true, + isDue8To30Days: false ), CacheManager.CustomTask( id: 2, @@ -827,7 +829,9 @@ struct Casera: Widget { dueDate: "2024-12-20", category: "painting", residenceName: "Home", - isOverdue: false + isOverdue: false, + isDueWithin7Days: true, + isDue8To30Days: false ), CacheManager.CustomTask( id: 3, @@ -838,7 +842,9 @@ struct Casera: Widget { dueDate: "2024-12-25", category: "maintenance", residenceName: "Home", - isOverdue: false + isOverdue: false, + isDueWithin7Days: false, + isDue8To30Days: true ) ], isInteractive: false @@ -868,7 +874,9 @@ struct Casera: Widget { dueDate: "2024-12-15", category: "plumbing", residenceName: "Home", - isOverdue: true + isOverdue: true, + isDueWithin7Days: false, + isDue8To30Days: false ), CacheManager.CustomTask( id: 2, @@ -879,7 +887,9 @@ struct Casera: Widget { dueDate: "2024-12-20", category: "painting", residenceName: "Cabin", - isOverdue: false + isOverdue: false, + isDueWithin7Days: true, + isDue8To30Days: false ), CacheManager.CustomTask( id: 3, @@ -890,7 +900,9 @@ struct Casera: Widget { dueDate: "2024-12-25", category: "maintenance", residenceName: "Home", - isOverdue: false + isOverdue: false, + isDueWithin7Days: false, + isDue8To30Days: true ) ], isInteractive: true @@ -910,7 +922,9 @@ struct Casera: Widget { dueDate: nil, category: nil, residenceName: nil, - isOverdue: false + isOverdue: false, + isDueWithin7Days: true, + isDue8To30Days: false ), CacheManager.CustomTask( id: 2, @@ -921,7 +935,9 @@ struct Casera: Widget { dueDate: nil, category: nil, residenceName: nil, - isOverdue: false + isOverdue: false, + isDueWithin7Days: true, + isDue8To30Days: false ), CacheManager.CustomTask( id: 3, @@ -932,7 +948,9 @@ struct Casera: Widget { dueDate: nil, category: nil, residenceName: nil, - isOverdue: false + isOverdue: false, + isDueWithin7Days: false, + isDue8To30Days: true ), CacheManager.CustomTask( id: 4, @@ -943,7 +961,9 @@ struct Casera: Widget { dueDate: nil, category: nil, residenceName: nil, - isOverdue: false + isOverdue: false, + isDueWithin7Days: false, + isDue8To30Days: true ), CacheManager.CustomTask( id: 5, @@ -954,7 +974,9 @@ struct Casera: Widget { dueDate: nil, category: nil, residenceName: nil, - isOverdue: false + isOverdue: false, + isDueWithin7Days: true, + isDue8To30Days: false ) ], isInteractive: false @@ -984,7 +1006,9 @@ struct Casera: Widget { dueDate: "2024-12-15", category: "plumbing", residenceName: "Home", - isOverdue: true + isOverdue: true, + isDueWithin7Days: false, + isDue8To30Days: false ), CacheManager.CustomTask( id: 2, @@ -995,7 +1019,9 @@ struct Casera: Widget { dueDate: "2024-12-20", category: "painting", residenceName: "Cabin", - isOverdue: false + isOverdue: false, + isDueWithin7Days: true, + isDue8To30Days: false ), CacheManager.CustomTask( id: 3, @@ -1006,7 +1032,9 @@ struct Casera: Widget { dueDate: "2024-12-25", category: "maintenance", residenceName: "Home", - isOverdue: false + isOverdue: false, + isDueWithin7Days: true, + isDue8To30Days: false ), CacheManager.CustomTask( id: 4, @@ -1017,7 +1045,9 @@ struct Casera: Widget { dueDate: "2024-12-28", category: "hvac", residenceName: "Beach House", - isOverdue: false + isOverdue: false, + isDueWithin7Days: true, + isDue8To30Days: false ), CacheManager.CustomTask( id: 5, @@ -1028,7 +1058,9 @@ struct Casera: Widget { dueDate: "2024-12-30", category: "safety", residenceName: "Home", - isOverdue: false + isOverdue: false, + isDueWithin7Days: false, + isDue8To30Days: true ), CacheManager.CustomTask( id: 6, @@ -1039,7 +1071,9 @@ struct Casera: Widget { dueDate: "2025-01-05", category: "plumbing", residenceName: "Cabin", - isOverdue: false + isOverdue: false, + isDueWithin7Days: false, + isDue8To30Days: true ), CacheManager.CustomTask( id: 7, @@ -1050,7 +1084,9 @@ struct Casera: Widget { dueDate: "2025-01-10", category: "exterior", residenceName: "Home", - isOverdue: false + isOverdue: false, + isDueWithin7Days: false, + isDue8To30Days: true ), CacheManager.CustomTask( id: 8, @@ -1061,7 +1097,9 @@ struct Casera: Widget { dueDate: "2025-01-12", category: "appliances", residenceName: "Beach House", - isOverdue: false + isOverdue: false, + isDueWithin7Days: false, + isDue8To30Days: true ) ], isInteractive: true @@ -1081,7 +1119,9 @@ struct Casera: Widget { dueDate: nil, category: nil, residenceName: nil, - isOverdue: false + isOverdue: false, + isDueWithin7Days: false, + isDue8To30Days: false ), CacheManager.CustomTask( id: 2, @@ -1092,7 +1132,9 @@ struct Casera: Widget { dueDate: nil, category: nil, residenceName: nil, - isOverdue: false + isOverdue: false, + isDueWithin7Days: false, + isDue8To30Days: false ), CacheManager.CustomTask( id: 3, @@ -1103,7 +1145,9 @@ struct Casera: Widget { dueDate: nil, category: nil, residenceName: nil, - isOverdue: false + isOverdue: false, + isDueWithin7Days: false, + isDue8To30Days: false ), CacheManager.CustomTask( id: 4, @@ -1114,7 +1158,9 @@ struct Casera: Widget { dueDate: nil, category: nil, residenceName: nil, - isOverdue: false + isOverdue: false, + isDueWithin7Days: false, + isDue8To30Days: false ), CacheManager.CustomTask( id: 5, @@ -1125,7 +1171,9 @@ struct Casera: Widget { dueDate: nil, category: nil, residenceName: nil, - isOverdue: false + isOverdue: false, + isDueWithin7Days: false, + isDue8To30Days: false ), CacheManager.CustomTask( id: 6, @@ -1136,7 +1184,9 @@ struct Casera: Widget { dueDate: nil, category: nil, residenceName: nil, - isOverdue: false + isOverdue: false, + isDueWithin7Days: false, + isDue8To30Days: false ), CacheManager.CustomTask( id: 7, @@ -1147,7 +1197,9 @@ struct Casera: Widget { dueDate: nil, category: nil, residenceName: nil, - isOverdue: false + isOverdue: false, + isDueWithin7Days: false, + isDue8To30Days: false ) ], isInteractive: false diff --git a/iosApp/CaseraTests/TaskMetricsTests.swift b/iosApp/CaseraTests/TaskMetricsTests.swift new file mode 100644 index 0000000..6e7ce35 --- /dev/null +++ b/iosApp/CaseraTests/TaskMetricsTests.swift @@ -0,0 +1,394 @@ +// +// TaskMetricsTests.swift +// CaseraTests +// +// Unit tests for WidgetDataManager.TaskMetrics and task categorization logic. +// + +import Testing +@testable import iosApp + +// MARK: - Column Name Constants Tests + +struct ColumnNameConstantsTests { + + @Test func overdueColumnName() { + #expect(WidgetDataManager.overdueColumn == "overdue_tasks") + } + + @Test func dueWithin7DaysColumnName() { + #expect(WidgetDataManager.dueWithin7DaysColumn == "due_soon_tasks") + } + + @Test func due8To30DaysColumnName() { + #expect(WidgetDataManager.due8To30DaysColumn == "upcoming_tasks") + } + + @Test func completedColumnName() { + #expect(WidgetDataManager.completedColumn == "completed_tasks") + } + + @Test func cancelledColumnName() { + #expect(WidgetDataManager.cancelledColumn == "cancelled_tasks") + } + + @Test func allColumnNamesUnique() { + let columns = [ + WidgetDataManager.overdueColumn, + WidgetDataManager.dueWithin7DaysColumn, + WidgetDataManager.due8To30DaysColumn, + WidgetDataManager.completedColumn, + WidgetDataManager.cancelledColumn + ] + #expect(Set(columns).count == columns.count) + } +} + +// MARK: - TaskMetrics Calculation Tests + +struct TaskMetricsCalculationTests { + + private func makeTask( + id: Int, + isOverdue: Bool = false, + isDueWithin7Days: Bool = false, + isDue8To30Days: Bool = false + ) -> WidgetDataManager.WidgetTask { + WidgetDataManager.WidgetTask( + id: id, + title: "Task \(id)", + description: nil, + priority: "medium", + inProgress: false, + dueDate: nil, + category: "maintenance", + residenceName: "Home", + isOverdue: isOverdue, + isDueWithin7Days: isDueWithin7Days, + isDue8To30Days: isDue8To30Days + ) + } + + @Test func emptyArrayReturnsZeroCounts() { + let metrics = WidgetDataManager.calculateMetrics(from: []) + #expect(metrics.totalCount == 0) + #expect(metrics.overdueCount == 0) + #expect(metrics.upcoming7Days == 0) + #expect(metrics.upcoming30Days == 0) + } + + @Test func allOverdueTasks() { + let tasks = [ + makeTask(id: 1, isOverdue: true), + makeTask(id: 2, isOverdue: true), + makeTask(id: 3, isOverdue: true) + ] + let metrics = WidgetDataManager.calculateMetrics(from: tasks) + #expect(metrics.totalCount == 3) + #expect(metrics.overdueCount == 3) + #expect(metrics.upcoming7Days == 0) + #expect(metrics.upcoming30Days == 0) + } + + @Test func allDueWithin7DaysTasks() { + let tasks = [ + makeTask(id: 1, isDueWithin7Days: true), + makeTask(id: 2, isDueWithin7Days: true), + makeTask(id: 3, isDueWithin7Days: true), + makeTask(id: 4, isDueWithin7Days: true) + ] + let metrics = WidgetDataManager.calculateMetrics(from: tasks) + #expect(metrics.totalCount == 4) + #expect(metrics.overdueCount == 0) + #expect(metrics.upcoming7Days == 4) + #expect(metrics.upcoming30Days == 0) + } + + @Test func allDue8To30DaysTasks() { + let tasks = [ + makeTask(id: 1, isDue8To30Days: true), + makeTask(id: 2, isDue8To30Days: true) + ] + let metrics = WidgetDataManager.calculateMetrics(from: tasks) + #expect(metrics.totalCount == 2) + #expect(metrics.overdueCount == 0) + #expect(metrics.upcoming7Days == 0) + #expect(metrics.upcoming30Days == 2) + } + + @Test func mixedTaskCategories() { + let tasks = [ + makeTask(id: 1, isOverdue: true), + makeTask(id: 2, isOverdue: true), + makeTask(id: 3, isDueWithin7Days: true), + makeTask(id: 4, isDueWithin7Days: true), + makeTask(id: 5, isDueWithin7Days: true), + makeTask(id: 6, isDue8To30Days: true) + ] + let metrics = WidgetDataManager.calculateMetrics(from: tasks) + #expect(metrics.totalCount == 6) + #expect(metrics.overdueCount == 2) + #expect(metrics.upcoming7Days == 3) + #expect(metrics.upcoming30Days == 1) + } + + @Test func tasksWithNoFlagsCountedInTotalOnly() { + let tasks = [ + makeTask(id: 1), + makeTask(id: 2), + makeTask(id: 3, isOverdue: true) + ] + let metrics = WidgetDataManager.calculateMetrics(from: tasks) + #expect(metrics.totalCount == 3) + #expect(metrics.overdueCount == 1) + #expect(metrics.upcoming7Days == 0) + #expect(metrics.upcoming30Days == 0) + } + + @Test func singleTask() { + let tasks = [makeTask(id: 1, isOverdue: true)] + let metrics = WidgetDataManager.calculateMetrics(from: tasks) + #expect(metrics.totalCount == 1) + #expect(metrics.overdueCount == 1) + } + + @Test func largeDataset() { + var tasks: [WidgetDataManager.WidgetTask] = [] + for i in 1...50 { tasks.append(makeTask(id: i, isOverdue: true)) } + for i in 51...150 { tasks.append(makeTask(id: i, isDueWithin7Days: true)) } + for i in 151...225 { tasks.append(makeTask(id: i, isDue8To30Days: true)) } + for i in 226...250 { tasks.append(makeTask(id: i)) } + + let metrics = WidgetDataManager.calculateMetrics(from: tasks) + #expect(metrics.totalCount == 250) + #expect(metrics.overdueCount == 50) + #expect(metrics.upcoming7Days == 100) + #expect(metrics.upcoming30Days == 75) + } + + @Test func orderDoesNotAffectMetrics() { + let tasks1 = [ + makeTask(id: 1, isOverdue: true), + makeTask(id: 2, isDueWithin7Days: true), + makeTask(id: 3, isDue8To30Days: true) + ] + let tasks2 = [ + makeTask(id: 3, isDue8To30Days: true), + makeTask(id: 1, isOverdue: true), + makeTask(id: 2, isDueWithin7Days: true) + ] + let m1 = WidgetDataManager.calculateMetrics(from: tasks1) + let m2 = WidgetDataManager.calculateMetrics(from: tasks2) + #expect(m1.totalCount == m2.totalCount) + #expect(m1.overdueCount == m2.overdueCount) + #expect(m1.upcoming7Days == m2.upcoming7Days) + #expect(m1.upcoming30Days == m2.upcoming30Days) + } +} + +// MARK: - TaskMetrics Struct Tests + +struct TaskMetricsStructTests { + + @Test func initializesWithCorrectValues() { + let metrics = WidgetDataManager.TaskMetrics( + totalCount: 10, + overdueCount: 2, + upcoming7Days: 3, + upcoming30Days: 5 + ) + #expect(metrics.totalCount == 10) + #expect(metrics.overdueCount == 2) + #expect(metrics.upcoming7Days == 3) + #expect(metrics.upcoming30Days == 5) + } + + @Test func allowsZeroValues() { + let metrics = WidgetDataManager.TaskMetrics( + totalCount: 0, + overdueCount: 0, + upcoming7Days: 0, + upcoming30Days: 0 + ) + #expect(metrics.totalCount == 0) + #expect(metrics.overdueCount == 0) + #expect(metrics.upcoming7Days == 0) + #expect(metrics.upcoming30Days == 0) + } +} + +// MARK: - WidgetTask Codable Tests + +struct WidgetTaskCodableTests { + + @Test func decodesFromJSON() throws { + let json = """ + { + "id": 123, + "title": "Test Task", + "description": "Desc", + "priority": "high", + "in_progress": true, + "due_date": "2024-12-25", + "category": "plumbing", + "residence_name": "Home", + "is_overdue": false, + "is_due_within_7_days": true, + "is_due_8_to_30_days": false + } + """.data(using: .utf8)! + + let task = try JSONDecoder().decode(WidgetDataManager.WidgetTask.self, from: json) + #expect(task.id == 123) + #expect(task.title == "Test Task") + #expect(task.description == "Desc") + #expect(task.priority == "high") + #expect(task.inProgress == true) + #expect(task.dueDate == "2024-12-25") + #expect(task.category == "plumbing") + #expect(task.residenceName == "Home") + #expect(task.isOverdue == false) + #expect(task.isDueWithin7Days == true) + #expect(task.isDue8To30Days == false) + } + + @Test func encodesToJSON() throws { + let task = WidgetDataManager.WidgetTask( + id: 456, + title: "Encode Test", + description: nil, + priority: "medium", + inProgress: false, + dueDate: "2024-06-01", + category: "hvac", + residenceName: nil, + isOverdue: true, + isDueWithin7Days: false, + isDue8To30Days: false + ) + + let data = try JSONEncoder().encode(task) + let json = String(data: data, encoding: .utf8)! + #expect(json.contains("\"id\":456")) + #expect(json.contains("\"is_overdue\":true")) + #expect(json.contains("\"in_progress\":false")) + } + + @Test func roundTripsJSON() throws { + let original = WidgetDataManager.WidgetTask( + id: 789, + title: "Round Trip", + description: "Testing", + priority: "low", + inProgress: true, + dueDate: "2024-03-15", + category: "landscaping", + residenceName: "Cabin", + isOverdue: false, + isDueWithin7Days: true, + isDue8To30Days: false + ) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(WidgetDataManager.WidgetTask.self, from: data) + + #expect(decoded.id == original.id) + #expect(decoded.title == original.title) + #expect(decoded.description == original.description) + #expect(decoded.inProgress == original.inProgress) + #expect(decoded.isOverdue == original.isOverdue) + #expect(decoded.isDueWithin7Days == original.isDueWithin7Days) + #expect(decoded.isDue8To30Days == original.isDue8To30Days) + } + + @Test func handlesNilOptionalFields() throws { + let json = """ + { + "id": 1, + "title": "Minimal", + "description": null, + "priority": null, + "in_progress": false, + "due_date": null, + "category": null, + "residence_name": null, + "is_overdue": false, + "is_due_within_7_days": false, + "is_due_8_to_30_days": false + } + """.data(using: .utf8)! + + let task = try JSONDecoder().decode(WidgetDataManager.WidgetTask.self, from: json) + #expect(task.id == 1) + #expect(task.description == nil) + #expect(task.priority == nil) + #expect(task.dueDate == nil) + #expect(task.category == nil) + #expect(task.residenceName == nil) + } +} + +// MARK: - Realistic Scenario Tests + +struct RealisticScenarioTests { + + private func makeTask( + id: Int, + title: String, + isOverdue: Bool = false, + isDueWithin7Days: Bool = false, + isDue8To30Days: Bool = false + ) -> WidgetDataManager.WidgetTask { + WidgetDataManager.WidgetTask( + id: id, + title: title, + description: nil, + priority: "medium", + inProgress: false, + dueDate: nil, + category: "maintenance", + residenceName: "Home", + isOverdue: isOverdue, + isDueWithin7Days: isDueWithin7Days, + isDue8To30Days: isDue8To30Days + ) + } + + @Test func newUserWithNoTasks() { + let metrics = WidgetDataManager.calculateMetrics(from: []) + #expect(metrics.totalCount == 0) + #expect(metrics.overdueCount == 0) + } + + @Test func userWithOverdueTasks() { + let tasks = [ + makeTask(id: 1, title: "HVAC maintenance", isOverdue: true), + makeTask(id: 2, title: "Roof inspection", isOverdue: true), + makeTask(id: 3, title: "Weekly lawn care", isDueWithin7Days: true), + makeTask(id: 4, title: "Monthly pest control", isDue8To30Days: true) + ] + let metrics = WidgetDataManager.calculateMetrics(from: tasks) + #expect(metrics.totalCount == 4) + #expect(metrics.overdueCount == 2) + #expect(metrics.upcoming7Days == 1) + #expect(metrics.upcoming30Days == 1) + } + + @Test func typicalHomeowner() { + let tasks = [ + makeTask(id: 1, title: "Replace water heater anode", isOverdue: true), + makeTask(id: 2, title: "Mow lawn", isDueWithin7Days: true), + makeTask(id: 3, title: "Take out trash", isDueWithin7Days: true), + makeTask(id: 4, title: "Water plants", isDueWithin7Days: true), + makeTask(id: 5, title: "Change air filter", isDue8To30Days: true), + makeTask(id: 6, title: "Clean dryer vent", isDue8To30Days: true), + makeTask(id: 7, title: "Organize garage"), + makeTask(id: 8, title: "Paint fence") + ] + let metrics = WidgetDataManager.calculateMetrics(from: tasks) + #expect(metrics.totalCount == 8) + #expect(metrics.overdueCount == 1) + #expect(metrics.upcoming7Days == 3) + #expect(metrics.upcoming30Days == 2) + } +} diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index b1acb34..f3c6569 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -138,7 +138,6 @@ Design/DesignSystem.swift, Design/OrganicDesign.swift, Helpers/ThemeManager.swift, - Shared/TaskStatsCalculator.swift, ); target = 1C07893C2EBC218B00392B46 /* CaseraExtension */; }; diff --git a/iosApp/iosApp/Data/DataManagerObservable.swift b/iosApp/iosApp/Data/DataManagerObservable.swift index 9dd6bb9..9658fb6 100644 --- a/iosApp/iosApp/Data/DataManagerObservable.swift +++ b/iosApp/iosApp/Data/DataManagerObservable.swift @@ -489,6 +489,81 @@ class DataManagerObservable: ObservableObject { return response.columns.allSatisfy { $0.tasks.isEmpty } } + // MARK: - Task Stats (Single Source of Truth) + // Uses API column names + shared calculateMetrics function + + /// Active tasks (excludes completed and cancelled) + var activeTasks: [TaskResponse] { + guard let response = allTasks else { return [] } + var tasks: [TaskResponse] = [] + for column in response.columns { + let columnName = column.name.lowercased() + if columnName == "completed_tasks" || columnName == "cancelled_tasks" { + continue + } + tasks.append(contentsOf: column.tasks) + } + return tasks + } + + /// Get tasks from a specific column by name + private func tasksInColumn(_ columnName: String) -> [TaskResponse] { + guard let response = allTasks else { return [] } + return response.columns.first { $0.name.lowercased() == columnName.lowercased() }?.tasks ?? [] + } + + /// Convert TaskResponse array to WidgetTask array for shared calculator + private func toWidgetTasks(_ tasks: [TaskResponse], overdueIds: Set, dueWithin7DaysIds: Set, due8To30DaysIds: Set) -> [WidgetDataManager.WidgetTask] { + return tasks.map { task in + WidgetDataManager.WidgetTask( + id: Int(task.id), + title: task.title, + description: task.description_, + priority: task.priorityName, + inProgress: task.inProgress, + dueDate: task.effectiveDueDate, + category: task.categoryName, + residenceName: nil, + isOverdue: overdueIds.contains(task.id), + isDueWithin7Days: dueWithin7DaysIds.contains(task.id), + isDue8To30Days: due8To30DaysIds.contains(task.id) + ) + } + } + + /// Get column ID sets (used by both stats and widget saving) + /// Uses column name constants from WidgetDataManager (single source of truth) + private var columnIdSets: (overdue: Set, dueWithin7Days: Set, due8To30Days: Set) { + ( + overdue: Set(tasksInColumn(WidgetDataManager.overdueColumn).map { $0.id }), + dueWithin7Days: Set(tasksInColumn(WidgetDataManager.dueWithin7DaysColumn).map { $0.id }), + due8To30Days: Set(tasksInColumn(WidgetDataManager.due8To30DaysColumn).map { $0.id }) + ) + } + + /// Calculate task metrics for a given set of tasks + /// Returns WidgetDataManager.TaskMetrics - the single source of truth for all metrics + private func calculateMetrics(for tasks: [TaskResponse]) -> WidgetDataManager.TaskMetrics { + let ids = columnIdSets + let widgetTasks = toWidgetTasks(tasks, overdueIds: ids.overdue, dueWithin7DaysIds: ids.dueWithin7Days, due8To30DaysIds: ids.due8To30Days) + return WidgetDataManager.calculateMetrics(from: widgetTasks) + } + + /// Total task metrics across all residences - USE THIS FOR DASHBOARD SUMMARY + var totalTaskMetrics: WidgetDataManager.TaskMetrics { + calculateMetrics(for: activeTasks) + } + + /// Task metrics for a specific residence - USE THIS FOR RESIDENCE CARDS + func taskMetrics(for residenceId: Int32) -> WidgetDataManager.TaskMetrics { + calculateMetrics(for: activeTasks.filter { $0.residenceId == residenceId }) + } + + /// Active task count for a specific residence + func activeTaskCount(for residenceId: Int32) -> Int { + return activeTasks.filter { $0.residenceId == residenceId }.count + } + // MARK: - Task Template Helpers /// Search task templates by query string diff --git a/iosApp/iosApp/Helpers/WidgetDataManager.swift b/iosApp/iosApp/Helpers/WidgetDataManager.swift index f40aef0..93fd517 100644 --- a/iosApp/iosApp/Helpers/WidgetDataManager.swift +++ b/iosApp/iosApp/Helpers/WidgetDataManager.swift @@ -7,6 +7,14 @@ import ComposeApp final class WidgetDataManager { static let shared = WidgetDataManager() + // MARK: - API Column Names (Single Source of Truth) + // These match the column names returned by the API's task columns endpoint + static let overdueColumn = "overdue_tasks" + static let dueWithin7DaysColumn = "due_soon_tasks" + static let due8To30DaysColumn = "upcoming_tasks" + static let completedColumn = "completed_tasks" + static let cancelledColumn = "cancelled_tasks" + private let appGroupIdentifier = "group.com.tt.casera.CaseraDev" private let tasksFileName = "widget_tasks.json" private let actionsFileName = "widget_pending_actions.json" @@ -228,6 +236,8 @@ final class WidgetDataManager { let category: String? let residenceName: String? let isOverdue: Bool + let isDueWithin7Days: Bool + let isDue8To30Days: Bool enum CodingKeys: String, CodingKey { case id, title, description, priority, category @@ -235,9 +245,41 @@ final class WidgetDataManager { case dueDate = "due_date" case residenceName = "residence_name" case isOverdue = "is_overdue" + case isDueWithin7Days = "is_due_within_7_days" + case isDue8To30Days = "is_due_8_to_30_days" } } + /// Metrics calculated from an array of tasks - shared between app and widget + struct TaskMetrics { + let totalCount: Int + let overdueCount: Int + let upcoming7Days: Int + let upcoming30Days: Int + } + + /// Calculate metrics from an array of WidgetTasks + /// This is the SINGLE SOURCE OF TRUTH for all task metrics + /// Used by: dashboard summary, residence cards, widget + static func calculateMetrics(from tasks: [WidgetTask]) -> TaskMetrics { + var overdueCount = 0 + var upcoming7Days = 0 + var upcoming30Days = 0 + + for task in tasks { + if task.isOverdue { overdueCount += 1 } + if task.isDueWithin7Days { upcoming7Days += 1 } + if task.isDue8To30Days { upcoming30Days += 1 } + } + + return TaskMetrics( + totalCount: tasks.count, + overdueCount: overdueCount, + upcoming7Days: upcoming7Days, + upcoming30Days: upcoming30Days + ) + } + /// Get the shared App Group container URL private var sharedContainerURL: URL? { FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) @@ -249,7 +291,6 @@ final class WidgetDataManager { } /// Save tasks to the shared container for widget access - /// Call this after loading tasks in the main app func saveTasks(from response: TaskColumnsResponse) { guard let fileURL = tasksFileURL else { print("WidgetDataManager: Unable to access shared container") @@ -257,7 +298,7 @@ final class WidgetDataManager { } // Columns to exclude from widget (these are "done" states) - let excludedColumns = ["completed_tasks", "cancelled_tasks"] + let excludedColumns = [Self.completedColumn, Self.cancelledColumn] // Extract tasks from active columns only and convert to WidgetTask var allTasks: [WidgetTask] = [] @@ -268,6 +309,11 @@ final class WidgetDataManager { continue } + // Determine flags based on column name (using shared constants) + let isOverdue = column.name == Self.overdueColumn + let isDueWithin7Days = column.name == Self.dueWithin7DaysColumn + let isDue8To30Days = column.name == Self.due8To30DaysColumn + for task in column.tasks { let widgetTask = WidgetTask( id: Int(task.id), @@ -275,10 +321,12 @@ final class WidgetDataManager { description: task.description_, priority: task.priorityName ?? "", inProgress: task.inProgress, - dueDate: task.effectiveDueDate, // Use effective date (nextDueDate if set, otherwise dueDate) + dueDate: task.effectiveDueDate, category: task.categoryName ?? "", - residenceName: "", // No longer available in API, residence lookup needed - isOverdue: column.name == "overdue_tasks" + residenceName: "", + isOverdue: isOverdue, + isDueWithin7Days: isDueWithin7Days, + isDue8To30Days: isDue8To30Days ) allTasks.append(widgetTask) } @@ -355,36 +403,4 @@ final class WidgetDataManager { } } - /// Check if a task is overdue based on due date and status - private func isTaskOverdue(dueDate: String?, status: String?) -> Bool { - guard let dueDateStr = dueDate else { return false } - - var date: Date? - - // Try parsing as yyyy-MM-dd first - let dateOnlyFormatter = DateFormatter() - dateOnlyFormatter.dateFormat = "yyyy-MM-dd" - date = dateOnlyFormatter.date(from: dueDateStr) - - // Try parsing as ISO8601 (RFC3339) if that fails - if date == nil { - let isoFormatter = ISO8601DateFormatter() - isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - date = isoFormatter.date(from: dueDateStr) - - // Try without fractional seconds - if date == nil { - isoFormatter.formatOptions = [.withInternetDateTime] - date = isoFormatter.date(from: dueDateStr) - } - } - - guard let parsedDate = date else { return false } - - // Task is overdue if due date is in the past and status is not completed - let statusLower = status?.lowercased() ?? "" - let isCompleted = statusLower == "completed" || statusLower == "done" - - return !isCompleted && parsedDate < Calendar.current.startOfDay(for: Date()) - } } diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index 9264b5c..9722461 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -23,10 +23,7 @@ struct ResidencesListView: View { isLoading: viewModel.isLoading, errorMessage: viewModel.errorMessage, content: { residences in - ResidencesContent( - residences: residences, - tasksResponse: taskViewModel.tasksResponse - ) + ResidencesContent(residences: residences) }, emptyContent: { OrganicEmptyResidencesView() @@ -170,67 +167,28 @@ private struct OrganicToolbarButton: View { } } -// MARK: - Residence Task Stats -struct ResidenceTaskStats { - let totalCount: Int - let overdueCount: Int - let dueThisWeekCount: Int - let dueNext30DaysCount: Int -} - // MARK: - Residences Content View private struct ResidencesContent: View { let residences: [ResidenceResponse] - let tasksResponse: TaskColumnsResponse? + @ObservedObject private var dataManager = DataManagerObservable.shared - /// Extract active tasks - skip completed_tasks and cancelled_tasks columns - private var activeTasks: [TaskResponse] { - guard let response = tasksResponse else { return [] } - - var tasks: [TaskResponse] = [] - for column in response.columns { - // Skip completed and cancelled columns (cancelled includes archived) - let columnName = column.name.lowercased() - if columnName == "completed_tasks" || columnName == "cancelled_tasks" { - continue - } - - // Add tasks from this column - for task in column.tasks { - tasks.append(task) - } - } - return tasks - } - - /// Compute total summary from task data using shared TaskStatsCalculator + /// Compute total summary from DataManagerObservable (single source of truth) private var computedSummary: TotalSummary { - let dueDates = activeTasks.map { $0.effectiveDueDate } - let stats = TaskStatsCalculator.calculate(from: dueDates) - + let metrics = dataManager.totalTaskMetrics return TotalSummary( totalResidences: Int32(residences.count), - totalTasks: Int32(activeTasks.count), + totalTasks: Int32(dataManager.activeTasks.count), totalPending: 0, - totalOverdue: Int32(stats.overdueCount), - tasksDueNextWeek: Int32(stats.next7DaysCount), - tasksDueNextMonth: Int32(stats.next30DaysCount) + totalOverdue: Int32(metrics.overdueCount), + tasksDueNextWeek: Int32(metrics.upcoming7Days), + tasksDueNextMonth: Int32(metrics.upcoming30Days) ) } - /// Get task stats for a specific residence using shared TaskStatsCalculator - private func taskStats(for residenceId: Int32) -> ResidenceTaskStats { - let residenceTasks = activeTasks.filter { $0.residenceId == residenceId } - let dueDates = residenceTasks.map { $0.effectiveDueDate } - let stats = TaskStatsCalculator.calculate(from: dueDates) - - return ResidenceTaskStats( - totalCount: residenceTasks.count, - overdueCount: stats.overdueCount, - dueThisWeekCount: stats.next7DaysCount, - dueNext30DaysCount: stats.next30DaysCount - ) + /// Get task metrics for a specific residence from DataManagerObservable + private func taskMetrics(for residenceId: Int32) -> WidgetDataManager.TaskMetrics { + dataManager.taskMetrics(for: residenceId) } var body: some View { @@ -247,7 +205,7 @@ private struct ResidencesContent: View { NavigationLink(destination: ResidenceDetailView(residenceId: residence.id)) { ResidenceCard( residence: residence, - taskStats: taskStats(for: residence.id) + taskMetrics: taskMetrics(for: residence.id) ) .padding(.horizontal, 16) } diff --git a/iosApp/iosApp/Shared/TaskStatsCalculator.swift b/iosApp/iosApp/Shared/TaskStatsCalculator.swift deleted file mode 100644 index 8e9c573..0000000 --- a/iosApp/iosApp/Shared/TaskStatsCalculator.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// TaskStatsCalculator.swift -// Casera -// -// Shared utility for calculating task statistics. -// This file is included in both the main app and widget targets -// to ensure consistent calculation logic across the app. -// - -import Foundation - -/// Task statistics with exclusive date buckets -struct TaskStats { - let totalCount: Int - let overdueCount: Int - let next7DaysCount: Int - let next30DaysCount: Int - - static let empty = TaskStats(totalCount: 0, overdueCount: 0, next7DaysCount: 0, next30DaysCount: 0) -} - -/// Calculator for task date statistics -/// Uses exclusive buckets matching the home dashboard logic: -/// - Overdue: due date < today -/// - Next 7 Days: today <= due date <= 7 days from now -/// - Next 30 Days: 7 days < due date <= 30 days from now -enum TaskStatsCalculator { - - /// Calculate task stats from an array of date strings - /// - Parameter dueDates: Array of optional date strings (yyyy-MM-dd or ISO8601 format) - /// - Returns: TaskStats with counts for each bucket - static func calculate(from dueDates: [String?]) -> TaskStats { - let calendar = Calendar.current - let today = calendar.startOfDay(for: Date()) - let in7Days = calendar.date(byAdding: .day, value: 7, to: today)! - let in30Days = calendar.date(byAdding: .day, value: 30, to: today)! - - var overdueCount = 0 - var next7DaysCount = 0 - var next30DaysCount = 0 - - for dueDateString in dueDates { - guard let dateStr = dueDateString, - let dueDate = parseDate(dateStr) else { - continue - } - - let taskDate = calendar.startOfDay(for: dueDate) - - if taskDate < today { - overdueCount += 1 - } else if taskDate <= in7Days { - next7DaysCount += 1 - } else if taskDate <= in30Days { - next30DaysCount += 1 - } - } - - return TaskStats( - totalCount: dueDates.count, - overdueCount: overdueCount, - next7DaysCount: next7DaysCount, - next30DaysCount: next30DaysCount - ) - } - - /// Calculate task stats for a specific residence - /// - Parameters: - /// - dueDates: Array of tuples containing (residenceId, dueDate) - /// - residenceId: The residence ID to filter by - /// - Returns: TaskStats for the specified residence - static func calculate(from dueDates: [(residenceId: Int32, dueDate: String?)], for residenceId: Int32) -> TaskStats { - let filtered = dueDates.filter { $0.residenceId == residenceId } - return calculate(from: filtered.map { $0.dueDate }) - } - - /// Parse date string to Date (supports yyyy-MM-dd and ISO8601 formats) - private static func parseDate(_ dateString: String) -> Date? { - // Try yyyy-MM-dd first - let dateOnlyFormatter = DateFormatter() - dateOnlyFormatter.dateFormat = "yyyy-MM-dd" - if let date = dateOnlyFormatter.date(from: dateString) { - return date - } - - // Try ISO8601 with fractional seconds - let isoFormatter = ISO8601DateFormatter() - isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = isoFormatter.date(from: dateString) { - return date - } - - // Try ISO8601 without fractional seconds - isoFormatter.formatOptions = [.withInternetDateTime] - return isoFormatter.date(from: dateString) - } -} diff --git a/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift b/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift index 14eb1ac..7df0968 100644 --- a/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift +++ b/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift @@ -3,11 +3,11 @@ import ComposeApp struct ResidenceCard: View { let residence: ResidenceResponse - let taskStats: ResidenceTaskStats + let taskMetrics: WidgetDataManager.TaskMetrics /// Check if this residence has any overdue tasks private var hasOverdueTasks: Bool { - taskStats.overdueCount > 0 + taskMetrics.overdueCount > 0 } /// Open the address in Apple Maps @@ -98,32 +98,32 @@ struct ResidenceCard: View { .padding(.horizontal, 16) // Task Stats Section - if taskStats.totalCount > 0 { + if taskMetrics.totalCount > 0 { HStack(spacing: 0) { // Total Tasks TaskStatItem( - value: taskStats.totalCount, + value: taskMetrics.totalCount, label: "Tasks", color: Color.appPrimary ) // Overdue TaskStatItem( - value: taskStats.overdueCount, + value: taskMetrics.overdueCount, label: "Overdue", - color: taskStats.overdueCount > 0 ? Color.appError : Color.appTextSecondary + color: taskMetrics.overdueCount > 0 ? Color.appError : Color.appTextSecondary ) // Due Next 7 Days TaskStatItem( - value: taskStats.dueThisWeekCount, + value: taskMetrics.upcoming7Days, label: "7 Days", color: Color.appAccent ) // Next 30 Days TaskStatItem( - value: taskStats.dueNext30DaysCount, + value: taskMetrics.upcoming30Days, label: "30 Days", color: Color.appPrimary.opacity(0.7) ) @@ -314,7 +314,7 @@ private struct CardBackgroundView: View { createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z" ), - taskStats: ResidenceTaskStats(totalCount: 8, overdueCount: 2, dueThisWeekCount: 3, dueNext30DaysCount: 2) + taskMetrics: WidgetDataManager.TaskMetrics(totalCount: 8, overdueCount: 2, upcoming7Days: 3, upcoming30Days: 2) ) ResidenceCard( @@ -346,7 +346,7 @@ private struct CardBackgroundView: View { createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z" ), - taskStats: ResidenceTaskStats(totalCount: 0, overdueCount: 0, dueThisWeekCount: 0, dueNext30DaysCount: 0) + taskMetrics: WidgetDataManager.TaskMetrics(totalCount: 0, overdueCount: 0, upcoming7Days: 0, upcoming30Days: 0) ) } .padding(.horizontal, 16)