diff --git a/iosApp/Casera/MyCrib.swift b/iosApp/Casera/MyCrib.swift index fa3f308..65da4dd 100644 --- a/iosApp/Casera/MyCrib.swift +++ b/iosApp/Casera/MyCrib.swift @@ -203,50 +203,26 @@ struct SimpleEntry: TimelineEntry { upcomingTasks.first } - /// Tasks due within the next 7 days - var dueThisWeekCount: Int { - let calendar = Calendar.current - let today = calendar.startOfDay(for: Date()) - let weekFromNow = calendar.date(byAdding: .day, value: 7, to: today)! - - return upcomingTasks.filter { task in - guard let dueDateString = task.dueDate else { return false } - guard let dueDate = parseDate(dueDateString) else { return false } - let dueDay = calendar.startOfDay(for: dueDate) - return dueDay <= weekFromNow - }.count + /// 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) } - /// Tasks due within the next 30 days + /// Overdue tasks count + var overdueCount: Int { + calculatedStats.overdueCount + } + + /// Tasks due within the next 7 days (exclusive of overdue) + var dueNext7DaysCount: Int { + calculatedStats.next7DaysCount + } + + /// Tasks due within next 30 days (days 8-30, exclusive of next 7 days) var dueNext30DaysCount: Int { - let calendar = Calendar.current - let today = calendar.startOfDay(for: Date()) - let thirtyDaysFromNow = calendar.date(byAdding: .day, value: 30, to: today)! - - return upcomingTasks.filter { task in - guard let dueDateString = task.dueDate else { return false } - guard let dueDate = parseDate(dueDateString) else { return false } - let dueDay = calendar.startOfDay(for: dueDate) - return dueDay <= thirtyDaysFromNow - }.count - } - - /// Parse date string to Date - private func parseDate(_ dateString: String) -> Date? { - let dateOnlyFormatter = DateFormatter() - dateOnlyFormatter.dateFormat = "yyyy-MM-dd" - if let date = dateOnlyFormatter.date(from: dateString) { - return date - } - - let isoFormatter = ISO8601DateFormatter() - isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = isoFormatter.date(from: dateString) { - return date - } - - isoFormatter.formatOptions = [.withInternetDateTime] - return isoFormatter.date(from: dateString) + calculatedStats.next30DaysCount } } @@ -561,30 +537,30 @@ struct LargeWidgetStatsView: View { var body: some View { HStack(spacing: 0) { - // Total Tasks + // Overdue StatItem( - value: entry.taskCount, - label: "Total", - color: .blue + value: entry.overdueCount, + label: "Overdue", + color: entry.overdueCount > 0 ? .red : .secondary ) Divider() .frame(height: 30) - // Due This Week + // Next 7 Days (exclusive of overdue) StatItem( - value: entry.dueThisWeekCount, - label: "This Week", + value: entry.dueNext7DaysCount, + label: "7 Days", color: .orange ) Divider() .frame(height: 30) - // Due Next 30 Days + // Next 30 Days (days 8-30) StatItem( value: entry.dueNext30DaysCount, - label: "Next 30 Days", + label: "30 Days", color: .green ) } diff --git a/iosApp/iosApp/MainTabView.swift b/iosApp/iosApp/MainTabView.swift index b6e8e70..31e6ec5 100644 --- a/iosApp/iosApp/MainTabView.swift +++ b/iosApp/iosApp/MainTabView.swift @@ -34,7 +34,7 @@ struct MainTabView: View { } .id(refreshID) .tabItem { - Label("Pros", systemImage: "wrench.and.screwdriver.fill") + Label("Contractors", systemImage: "wrench.and.screwdriver.fill") } .tag(2) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab) diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index f966617..9264b5c 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -204,78 +204,32 @@ private struct ResidencesContent: View { return tasks } - /// Compute total summary from task data using date logic + /// Compute total summary from task data using shared TaskStatsCalculator private var computedSummary: TotalSummary { - let calendar = Calendar.current - let today = calendar.startOfDay(for: Date()) - let in7Days = calendar.date(byAdding: .day, value: 7, to: today) ?? today - let in30Days = calendar.date(byAdding: .day, value: 30, to: today) ?? today - - var overdueCount: Int32 = 0 - var dueThisWeekCount: Int32 = 0 - var dueNext30DaysCount: Int32 = 0 - - for task in activeTasks { - guard let dueDateStr = task.effectiveDueDate, - let dueDate = DateUtils.parseDate(dueDateStr) else { - continue - } - - let taskDate = calendar.startOfDay(for: dueDate) - - if taskDate < today { - overdueCount += 1 - } else if taskDate <= in7Days { - dueThisWeekCount += 1 - } else if taskDate <= in30Days { - dueNext30DaysCount += 1 - } - } + let dueDates = activeTasks.map { $0.effectiveDueDate } + let stats = TaskStatsCalculator.calculate(from: dueDates) return TotalSummary( totalResidences: Int32(residences.count), totalTasks: Int32(activeTasks.count), totalPending: 0, - totalOverdue: overdueCount, - tasksDueNextWeek: dueThisWeekCount, - tasksDueNextMonth: dueNext30DaysCount + totalOverdue: Int32(stats.overdueCount), + tasksDueNextWeek: Int32(stats.next7DaysCount), + tasksDueNextMonth: Int32(stats.next30DaysCount) ) } - /// Get task stats for a specific residence + /// 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 calendar = Calendar.current - let today = calendar.startOfDay(for: Date()) - let in7Days = calendar.date(byAdding: .day, value: 7, to: today) ?? today - let in30Days = calendar.date(byAdding: .day, value: 30, to: today) ?? today - - var overdueCount = 0 - var dueThisWeekCount = 0 - var dueNext30DaysCount = 0 - - for task in residenceTasks { - guard let dueDateStr = task.effectiveDueDate, - let dueDate = DateUtils.parseDate(dueDateStr) else { - continue - } - - let taskDate = calendar.startOfDay(for: dueDate) - - if taskDate < today { - overdueCount += 1 - } else if taskDate <= in7Days { - dueThisWeekCount += 1 - } else if taskDate <= in30Days { - dueNext30DaysCount += 1 - } - } + let dueDates = residenceTasks.map { $0.effectiveDueDate } + let stats = TaskStatsCalculator.calculate(from: dueDates) return ResidenceTaskStats( totalCount: residenceTasks.count, - overdueCount: overdueCount, - dueThisWeekCount: dueThisWeekCount, - dueNext30DaysCount: dueNext30DaysCount + overdueCount: stats.overdueCount, + dueThisWeekCount: stats.next7DaysCount, + dueNext30DaysCount: stats.next30DaysCount ) } diff --git a/iosApp/iosApp/Shared/TaskStatsCalculator.swift b/iosApp/iosApp/Shared/TaskStatsCalculator.swift new file mode 100644 index 0000000..8e9c573 --- /dev/null +++ b/iosApp/iosApp/Shared/TaskStatsCalculator.swift @@ -0,0 +1,97 @@ +// +// 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) + } +} diff --git a/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift b/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift index 009479c..14eb1ac 100644 --- a/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift +++ b/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift @@ -114,10 +114,10 @@ struct ResidenceCard: View { color: taskStats.overdueCount > 0 ? Color.appError : Color.appTextSecondary ) - // Due This Week + // Due Next 7 Days TaskStatItem( value: taskStats.dueThisWeekCount, - label: "This Week", + label: "7 Days", color: Color.appAccent ) @@ -178,11 +178,6 @@ private struct PropertyIconView: View { .scaledToFit() .frame(width: 48, height: 48) .foregroundColor(Color.appTextOnPrimary) - - // Pulse ring for overdue - if hasOverdue { - PulseRing() - } } .naturalShadow(.subtle) } diff --git a/iosApp/iosApp/Subviews/Residence/SummaryCard.swift b/iosApp/iosApp/Subviews/Residence/SummaryCard.swift index 627cebc..06de467 100644 --- a/iosApp/iosApp/Subviews/Residence/SummaryCard.swift +++ b/iosApp/iosApp/Subviews/Residence/SummaryCard.swift @@ -57,7 +57,7 @@ struct SummaryCard: View { TimelineStatPill( icon: "clock.fill", value: "\(summary.tasksDueNextWeek)", - label: "Due This Week", + label: "Next 7 Days", color: Color.appAccent )