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 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
|
||||
|
||||
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/OrganicDesign.swift,
|
||||
Helpers/ThemeManager.swift,
|
||||
Shared/TaskStatsCalculator.swift,
|
||||
);
|
||||
target = 1C07893C2EBC218B00392B46 /* CaseraExtension */;
|
||||
};
|
||||
|
||||
@@ -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<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
|
||||
|
||||
/// Search task templates by query string
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user