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
+53 -37
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())
}
}