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:
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user