Consolidate task metrics to single source of truth
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,8 @@ class CacheManager {
|
|||||||
let category: String?
|
let category: String?
|
||||||
let residenceName: String?
|
let residenceName: String?
|
||||||
let isOverdue: Bool
|
let isOverdue: Bool
|
||||||
|
let isDueWithin7Days: Bool
|
||||||
|
let isDue8To30Days: Bool
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, title, description, priority, category
|
case id, title, description, priority, category
|
||||||
@@ -76,6 +78,8 @@ class CacheManager {
|
|||||||
case dueDate = "due_date"
|
case dueDate = "due_date"
|
||||||
case residenceName = "residence_name"
|
case residenceName = "residence_name"
|
||||||
case isOverdue = "is_overdue"
|
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)
|
/// Whether this task is pending completion (tapped on widget, waiting for sync)
|
||||||
@@ -203,27 +207,19 @@ struct SimpleEntry: TimelineEntry {
|
|||||||
upcomingTasks.first
|
upcomingTasks.first
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computed task stats using shared TaskStatsCalculator
|
/// Overdue tasks count - uses isOverdue flag from API column
|
||||||
/// 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
|
|
||||||
var overdueCount: Int {
|
var overdueCount: Int {
|
||||||
upcomingTasks.filter { $0.isOverdue }.count
|
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 {
|
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 {
|
var dueNext30DaysCount: Int {
|
||||||
calculatedStats.next30DaysCount
|
upcomingTasks.filter { $0.isDue8To30Days }.count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -785,7 +781,9 @@ struct Casera: Widget {
|
|||||||
dueDate: "2024-12-15",
|
dueDate: "2024-12-15",
|
||||||
category: "plumbing",
|
category: "plumbing",
|
||||||
residenceName: "Home",
|
residenceName: "Home",
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: true,
|
||||||
|
isDue8To30Days: false
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -796,7 +794,9 @@ struct Casera: Widget {
|
|||||||
dueDate: "2024-12-20",
|
dueDate: "2024-12-20",
|
||||||
category: "painting",
|
category: "painting",
|
||||||
residenceName: "Home",
|
residenceName: "Home",
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: true,
|
||||||
|
isDue8To30Days: false
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
isInteractive: true
|
isInteractive: true
|
||||||
@@ -816,7 +816,9 @@ struct Casera: Widget {
|
|||||||
dueDate: "2024-12-15",
|
dueDate: "2024-12-15",
|
||||||
category: "plumbing",
|
category: "plumbing",
|
||||||
residenceName: "Home",
|
residenceName: "Home",
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: true,
|
||||||
|
isDue8To30Days: false
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -827,7 +829,9 @@ struct Casera: Widget {
|
|||||||
dueDate: "2024-12-20",
|
dueDate: "2024-12-20",
|
||||||
category: "painting",
|
category: "painting",
|
||||||
residenceName: "Home",
|
residenceName: "Home",
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: true,
|
||||||
|
isDue8To30Days: false
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 3,
|
id: 3,
|
||||||
@@ -838,7 +842,9 @@ struct Casera: Widget {
|
|||||||
dueDate: "2024-12-25",
|
dueDate: "2024-12-25",
|
||||||
category: "maintenance",
|
category: "maintenance",
|
||||||
residenceName: "Home",
|
residenceName: "Home",
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: true
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
isInteractive: false
|
isInteractive: false
|
||||||
@@ -868,7 +874,9 @@ struct Casera: Widget {
|
|||||||
dueDate: "2024-12-15",
|
dueDate: "2024-12-15",
|
||||||
category: "plumbing",
|
category: "plumbing",
|
||||||
residenceName: "Home",
|
residenceName: "Home",
|
||||||
isOverdue: true
|
isOverdue: true,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: false
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -879,7 +887,9 @@ struct Casera: Widget {
|
|||||||
dueDate: "2024-12-20",
|
dueDate: "2024-12-20",
|
||||||
category: "painting",
|
category: "painting",
|
||||||
residenceName: "Cabin",
|
residenceName: "Cabin",
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: true,
|
||||||
|
isDue8To30Days: false
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 3,
|
id: 3,
|
||||||
@@ -890,7 +900,9 @@ struct Casera: Widget {
|
|||||||
dueDate: "2024-12-25",
|
dueDate: "2024-12-25",
|
||||||
category: "maintenance",
|
category: "maintenance",
|
||||||
residenceName: "Home",
|
residenceName: "Home",
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: true
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
isInteractive: true
|
isInteractive: true
|
||||||
@@ -910,7 +922,9 @@ struct Casera: Widget {
|
|||||||
dueDate: nil,
|
dueDate: nil,
|
||||||
category: nil,
|
category: nil,
|
||||||
residenceName: nil,
|
residenceName: nil,
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: true,
|
||||||
|
isDue8To30Days: false
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -921,7 +935,9 @@ struct Casera: Widget {
|
|||||||
dueDate: nil,
|
dueDate: nil,
|
||||||
category: nil,
|
category: nil,
|
||||||
residenceName: nil,
|
residenceName: nil,
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: true,
|
||||||
|
isDue8To30Days: false
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 3,
|
id: 3,
|
||||||
@@ -932,7 +948,9 @@ struct Casera: Widget {
|
|||||||
dueDate: nil,
|
dueDate: nil,
|
||||||
category: nil,
|
category: nil,
|
||||||
residenceName: nil,
|
residenceName: nil,
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: true
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 4,
|
id: 4,
|
||||||
@@ -943,7 +961,9 @@ struct Casera: Widget {
|
|||||||
dueDate: nil,
|
dueDate: nil,
|
||||||
category: nil,
|
category: nil,
|
||||||
residenceName: nil,
|
residenceName: nil,
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: true
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 5,
|
id: 5,
|
||||||
@@ -954,7 +974,9 @@ struct Casera: Widget {
|
|||||||
dueDate: nil,
|
dueDate: nil,
|
||||||
category: nil,
|
category: nil,
|
||||||
residenceName: nil,
|
residenceName: nil,
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: true,
|
||||||
|
isDue8To30Days: false
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
isInteractive: false
|
isInteractive: false
|
||||||
@@ -984,7 +1006,9 @@ struct Casera: Widget {
|
|||||||
dueDate: "2024-12-15",
|
dueDate: "2024-12-15",
|
||||||
category: "plumbing",
|
category: "plumbing",
|
||||||
residenceName: "Home",
|
residenceName: "Home",
|
||||||
isOverdue: true
|
isOverdue: true,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: false
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -995,7 +1019,9 @@ struct Casera: Widget {
|
|||||||
dueDate: "2024-12-20",
|
dueDate: "2024-12-20",
|
||||||
category: "painting",
|
category: "painting",
|
||||||
residenceName: "Cabin",
|
residenceName: "Cabin",
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: true,
|
||||||
|
isDue8To30Days: false
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 3,
|
id: 3,
|
||||||
@@ -1006,7 +1032,9 @@ struct Casera: Widget {
|
|||||||
dueDate: "2024-12-25",
|
dueDate: "2024-12-25",
|
||||||
category: "maintenance",
|
category: "maintenance",
|
||||||
residenceName: "Home",
|
residenceName: "Home",
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: true,
|
||||||
|
isDue8To30Days: false
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 4,
|
id: 4,
|
||||||
@@ -1017,7 +1045,9 @@ struct Casera: Widget {
|
|||||||
dueDate: "2024-12-28",
|
dueDate: "2024-12-28",
|
||||||
category: "hvac",
|
category: "hvac",
|
||||||
residenceName: "Beach House",
|
residenceName: "Beach House",
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: true,
|
||||||
|
isDue8To30Days: false
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 5,
|
id: 5,
|
||||||
@@ -1028,7 +1058,9 @@ struct Casera: Widget {
|
|||||||
dueDate: "2024-12-30",
|
dueDate: "2024-12-30",
|
||||||
category: "safety",
|
category: "safety",
|
||||||
residenceName: "Home",
|
residenceName: "Home",
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: true
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 6,
|
id: 6,
|
||||||
@@ -1039,7 +1071,9 @@ struct Casera: Widget {
|
|||||||
dueDate: "2025-01-05",
|
dueDate: "2025-01-05",
|
||||||
category: "plumbing",
|
category: "plumbing",
|
||||||
residenceName: "Cabin",
|
residenceName: "Cabin",
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: true
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 7,
|
id: 7,
|
||||||
@@ -1050,7 +1084,9 @@ struct Casera: Widget {
|
|||||||
dueDate: "2025-01-10",
|
dueDate: "2025-01-10",
|
||||||
category: "exterior",
|
category: "exterior",
|
||||||
residenceName: "Home",
|
residenceName: "Home",
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: true
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 8,
|
id: 8,
|
||||||
@@ -1061,7 +1097,9 @@ struct Casera: Widget {
|
|||||||
dueDate: "2025-01-12",
|
dueDate: "2025-01-12",
|
||||||
category: "appliances",
|
category: "appliances",
|
||||||
residenceName: "Beach House",
|
residenceName: "Beach House",
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: true
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
isInteractive: true
|
isInteractive: true
|
||||||
@@ -1081,7 +1119,9 @@ struct Casera: Widget {
|
|||||||
dueDate: nil,
|
dueDate: nil,
|
||||||
category: nil,
|
category: nil,
|
||||||
residenceName: nil,
|
residenceName: nil,
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: false
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -1092,7 +1132,9 @@ struct Casera: Widget {
|
|||||||
dueDate: nil,
|
dueDate: nil,
|
||||||
category: nil,
|
category: nil,
|
||||||
residenceName: nil,
|
residenceName: nil,
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: false
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 3,
|
id: 3,
|
||||||
@@ -1103,7 +1145,9 @@ struct Casera: Widget {
|
|||||||
dueDate: nil,
|
dueDate: nil,
|
||||||
category: nil,
|
category: nil,
|
||||||
residenceName: nil,
|
residenceName: nil,
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: false
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 4,
|
id: 4,
|
||||||
@@ -1114,7 +1158,9 @@ struct Casera: Widget {
|
|||||||
dueDate: nil,
|
dueDate: nil,
|
||||||
category: nil,
|
category: nil,
|
||||||
residenceName: nil,
|
residenceName: nil,
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: false
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 5,
|
id: 5,
|
||||||
@@ -1125,7 +1171,9 @@ struct Casera: Widget {
|
|||||||
dueDate: nil,
|
dueDate: nil,
|
||||||
category: nil,
|
category: nil,
|
||||||
residenceName: nil,
|
residenceName: nil,
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: false
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 6,
|
id: 6,
|
||||||
@@ -1136,7 +1184,9 @@ struct Casera: Widget {
|
|||||||
dueDate: nil,
|
dueDate: nil,
|
||||||
category: nil,
|
category: nil,
|
||||||
residenceName: nil,
|
residenceName: nil,
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: false
|
||||||
),
|
),
|
||||||
CacheManager.CustomTask(
|
CacheManager.CustomTask(
|
||||||
id: 7,
|
id: 7,
|
||||||
@@ -1147,7 +1197,9 @@ struct Casera: Widget {
|
|||||||
dueDate: nil,
|
dueDate: nil,
|
||||||
category: nil,
|
category: nil,
|
||||||
residenceName: nil,
|
residenceName: nil,
|
||||||
isOverdue: false
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: false
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
isInteractive: false
|
isInteractive: false
|
||||||
|
|||||||
394
iosApp/CaseraTests/TaskMetricsTests.swift
Normal file
394
iosApp/CaseraTests/TaskMetricsTests.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,7 +138,6 @@
|
|||||||
Design/DesignSystem.swift,
|
Design/DesignSystem.swift,
|
||||||
Design/OrganicDesign.swift,
|
Design/OrganicDesign.swift,
|
||||||
Helpers/ThemeManager.swift,
|
Helpers/ThemeManager.swift,
|
||||||
Shared/TaskStatsCalculator.swift,
|
|
||||||
);
|
);
|
||||||
target = 1C07893C2EBC218B00392B46 /* CaseraExtension */;
|
target = 1C07893C2EBC218B00392B46 /* CaseraExtension */;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -489,6 +489,81 @@ class DataManagerObservable: ObservableObject {
|
|||||||
return response.columns.allSatisfy { $0.tasks.isEmpty }
|
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<Int32>, dueWithin7DaysIds: Set<Int32>, due8To30DaysIds: Set<Int32>) -> [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<Int32>, dueWithin7Days: Set<Int32>, due8To30Days: Set<Int32>) {
|
||||||
|
(
|
||||||
|
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
|
// MARK: - Task Template Helpers
|
||||||
|
|
||||||
/// Search task templates by query string
|
/// Search task templates by query string
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import ComposeApp
|
|||||||
final class WidgetDataManager {
|
final class WidgetDataManager {
|
||||||
static let shared = 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 appGroupIdentifier = "group.com.tt.casera.CaseraDev"
|
||||||
private let tasksFileName = "widget_tasks.json"
|
private let tasksFileName = "widget_tasks.json"
|
||||||
private let actionsFileName = "widget_pending_actions.json"
|
private let actionsFileName = "widget_pending_actions.json"
|
||||||
@@ -228,6 +236,8 @@ final class WidgetDataManager {
|
|||||||
let category: String?
|
let category: String?
|
||||||
let residenceName: String?
|
let residenceName: String?
|
||||||
let isOverdue: Bool
|
let isOverdue: Bool
|
||||||
|
let isDueWithin7Days: Bool
|
||||||
|
let isDue8To30Days: Bool
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, title, description, priority, category
|
case id, title, description, priority, category
|
||||||
@@ -235,9 +245,41 @@ final class WidgetDataManager {
|
|||||||
case dueDate = "due_date"
|
case dueDate = "due_date"
|
||||||
case residenceName = "residence_name"
|
case residenceName = "residence_name"
|
||||||
case isOverdue = "is_overdue"
|
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
|
/// Get the shared App Group container URL
|
||||||
private var sharedContainerURL: URL? {
|
private var sharedContainerURL: URL? {
|
||||||
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
|
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
|
||||||
@@ -249,7 +291,6 @@ final class WidgetDataManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Save tasks to the shared container for widget access
|
/// Save tasks to the shared container for widget access
|
||||||
/// Call this after loading tasks in the main app
|
|
||||||
func saveTasks(from response: TaskColumnsResponse) {
|
func saveTasks(from response: TaskColumnsResponse) {
|
||||||
guard let fileURL = tasksFileURL else {
|
guard let fileURL = tasksFileURL else {
|
||||||
print("WidgetDataManager: Unable to access shared container")
|
print("WidgetDataManager: Unable to access shared container")
|
||||||
@@ -257,7 +298,7 @@ final class WidgetDataManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Columns to exclude from widget (these are "done" states)
|
// 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
|
// Extract tasks from active columns only and convert to WidgetTask
|
||||||
var allTasks: [WidgetTask] = []
|
var allTasks: [WidgetTask] = []
|
||||||
@@ -268,6 +309,11 @@ final class WidgetDataManager {
|
|||||||
continue
|
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 {
|
for task in column.tasks {
|
||||||
let widgetTask = WidgetTask(
|
let widgetTask = WidgetTask(
|
||||||
id: Int(task.id),
|
id: Int(task.id),
|
||||||
@@ -275,10 +321,12 @@ final class WidgetDataManager {
|
|||||||
description: task.description_,
|
description: task.description_,
|
||||||
priority: task.priorityName ?? "",
|
priority: task.priorityName ?? "",
|
||||||
inProgress: task.inProgress,
|
inProgress: task.inProgress,
|
||||||
dueDate: task.effectiveDueDate, // Use effective date (nextDueDate if set, otherwise dueDate)
|
dueDate: task.effectiveDueDate,
|
||||||
category: task.categoryName ?? "",
|
category: task.categoryName ?? "",
|
||||||
residenceName: "", // No longer available in API, residence lookup needed
|
residenceName: "",
|
||||||
isOverdue: column.name == "overdue_tasks"
|
isOverdue: isOverdue,
|
||||||
|
isDueWithin7Days: isDueWithin7Days,
|
||||||
|
isDue8To30Days: isDue8To30Days
|
||||||
)
|
)
|
||||||
allTasks.append(widgetTask)
|
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())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ struct ResidencesListView: View {
|
|||||||
isLoading: viewModel.isLoading,
|
isLoading: viewModel.isLoading,
|
||||||
errorMessage: viewModel.errorMessage,
|
errorMessage: viewModel.errorMessage,
|
||||||
content: { residences in
|
content: { residences in
|
||||||
ResidencesContent(
|
ResidencesContent(residences: residences)
|
||||||
residences: residences,
|
|
||||||
tasksResponse: taskViewModel.tasksResponse
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
emptyContent: {
|
emptyContent: {
|
||||||
OrganicEmptyResidencesView()
|
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
|
// MARK: - Residences Content View
|
||||||
|
|
||||||
private struct ResidencesContent: View {
|
private struct ResidencesContent: View {
|
||||||
let residences: [ResidenceResponse]
|
let residences: [ResidenceResponse]
|
||||||
let tasksResponse: TaskColumnsResponse?
|
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||||
|
|
||||||
/// Extract active tasks - skip completed_tasks and cancelled_tasks columns
|
/// Compute total summary from DataManagerObservable (single source of truth)
|
||||||
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
|
|
||||||
private var computedSummary: TotalSummary {
|
private var computedSummary: TotalSummary {
|
||||||
let dueDates = activeTasks.map { $0.effectiveDueDate }
|
let metrics = dataManager.totalTaskMetrics
|
||||||
let stats = TaskStatsCalculator.calculate(from: dueDates)
|
|
||||||
|
|
||||||
return TotalSummary(
|
return TotalSummary(
|
||||||
totalResidences: Int32(residences.count),
|
totalResidences: Int32(residences.count),
|
||||||
totalTasks: Int32(activeTasks.count),
|
totalTasks: Int32(dataManager.activeTasks.count),
|
||||||
totalPending: 0,
|
totalPending: 0,
|
||||||
totalOverdue: Int32(stats.overdueCount),
|
totalOverdue: Int32(metrics.overdueCount),
|
||||||
tasksDueNextWeek: Int32(stats.next7DaysCount),
|
tasksDueNextWeek: Int32(metrics.upcoming7Days),
|
||||||
tasksDueNextMonth: Int32(stats.next30DaysCount)
|
tasksDueNextMonth: Int32(metrics.upcoming30Days)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get task stats for a specific residence using shared TaskStatsCalculator
|
/// Get task metrics for a specific residence from DataManagerObservable
|
||||||
private func taskStats(for residenceId: Int32) -> ResidenceTaskStats {
|
private func taskMetrics(for residenceId: Int32) -> WidgetDataManager.TaskMetrics {
|
||||||
let residenceTasks = activeTasks.filter { $0.residenceId == residenceId }
|
dataManager.taskMetrics(for: 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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -247,7 +205,7 @@ private struct ResidencesContent: View {
|
|||||||
NavigationLink(destination: ResidenceDetailView(residenceId: residence.id)) {
|
NavigationLink(destination: ResidenceDetailView(residenceId: residence.id)) {
|
||||||
ResidenceCard(
|
ResidenceCard(
|
||||||
residence: residence,
|
residence: residence,
|
||||||
taskStats: taskStats(for: residence.id)
|
taskMetrics: taskMetrics(for: residence.id)
|
||||||
)
|
)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,11 +3,11 @@ import ComposeApp
|
|||||||
|
|
||||||
struct ResidenceCard: View {
|
struct ResidenceCard: View {
|
||||||
let residence: ResidenceResponse
|
let residence: ResidenceResponse
|
||||||
let taskStats: ResidenceTaskStats
|
let taskMetrics: WidgetDataManager.TaskMetrics
|
||||||
|
|
||||||
/// Check if this residence has any overdue tasks
|
/// Check if this residence has any overdue tasks
|
||||||
private var hasOverdueTasks: Bool {
|
private var hasOverdueTasks: Bool {
|
||||||
taskStats.overdueCount > 0
|
taskMetrics.overdueCount > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the address in Apple Maps
|
/// Open the address in Apple Maps
|
||||||
@@ -98,32 +98,32 @@ struct ResidenceCard: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
// Task Stats Section
|
// Task Stats Section
|
||||||
if taskStats.totalCount > 0 {
|
if taskMetrics.totalCount > 0 {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
// Total Tasks
|
// Total Tasks
|
||||||
TaskStatItem(
|
TaskStatItem(
|
||||||
value: taskStats.totalCount,
|
value: taskMetrics.totalCount,
|
||||||
label: "Tasks",
|
label: "Tasks",
|
||||||
color: Color.appPrimary
|
color: Color.appPrimary
|
||||||
)
|
)
|
||||||
|
|
||||||
// Overdue
|
// Overdue
|
||||||
TaskStatItem(
|
TaskStatItem(
|
||||||
value: taskStats.overdueCount,
|
value: taskMetrics.overdueCount,
|
||||||
label: "Overdue",
|
label: "Overdue",
|
||||||
color: taskStats.overdueCount > 0 ? Color.appError : Color.appTextSecondary
|
color: taskMetrics.overdueCount > 0 ? Color.appError : Color.appTextSecondary
|
||||||
)
|
)
|
||||||
|
|
||||||
// Due Next 7 Days
|
// Due Next 7 Days
|
||||||
TaskStatItem(
|
TaskStatItem(
|
||||||
value: taskStats.dueThisWeekCount,
|
value: taskMetrics.upcoming7Days,
|
||||||
label: "7 Days",
|
label: "7 Days",
|
||||||
color: Color.appAccent
|
color: Color.appAccent
|
||||||
)
|
)
|
||||||
|
|
||||||
// Next 30 Days
|
// Next 30 Days
|
||||||
TaskStatItem(
|
TaskStatItem(
|
||||||
value: taskStats.dueNext30DaysCount,
|
value: taskMetrics.upcoming30Days,
|
||||||
label: "30 Days",
|
label: "30 Days",
|
||||||
color: Color.appPrimary.opacity(0.7)
|
color: Color.appPrimary.opacity(0.7)
|
||||||
)
|
)
|
||||||
@@ -314,7 +314,7 @@ private struct CardBackgroundView: View {
|
|||||||
createdAt: "2024-01-01T00:00:00Z",
|
createdAt: "2024-01-01T00:00:00Z",
|
||||||
updatedAt: "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(
|
ResidenceCard(
|
||||||
@@ -346,7 +346,7 @@ private struct CardBackgroundView: View {
|
|||||||
createdAt: "2024-01-01T00:00:00Z",
|
createdAt: "2024-01-01T00:00:00Z",
|
||||||
updatedAt: "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)
|
.padding(.horizontal, 16)
|
||||||
|
|||||||
Reference in New Issue
Block a user