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:
Trey t
2025-12-23 20:26:52 -06:00
parent cacdf86938
commit 4daaa1f7d8
8 changed files with 637 additions and 240 deletions

View File

@@ -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

View 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)
}
}

View File

@@ -138,7 +138,6 @@
Design/DesignSystem.swift,
Design/OrganicDesign.swift,
Helpers/ThemeManager.swift,
Shared/TaskStatsCalculator.swift,
);
target = 1C07893C2EBC218B00392B46 /* CaseraExtension */;
};

View File

@@ -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

View File

@@ -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())
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)