Unify task stats calculation and update UI labels
- Create shared TaskStatsCalculator for consistent date bucket logic - Fix widget stats to use exclusive buckets (overdue | 7 days | 30 days) - Update labels: "This Week" → "Next 7 Days" / "7 Days" - Large widget now shows Overdue, 7 Days, 30 Days (removed Total) - Rename "Pros" tab to "Contractors" - Remove red pulsing ring from residence card icons 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -203,50 +203,26 @@ struct SimpleEntry: TimelineEntry {
|
|||||||
upcomingTasks.first
|
upcomingTasks.first
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tasks due within the next 7 days
|
/// Computed task stats using shared TaskStatsCalculator
|
||||||
var dueThisWeekCount: Int {
|
/// Uses exclusive buckets: overdue | next 7 days | next 30 days (8-30)
|
||||||
let calendar = Calendar.current
|
private var calculatedStats: TaskStats {
|
||||||
let today = calendar.startOfDay(for: Date())
|
let dueDates = upcomingTasks.map { $0.dueDate }
|
||||||
let weekFromNow = calendar.date(byAdding: .day, value: 7, to: today)!
|
return TaskStatsCalculator.calculate(from: dueDates)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
var dueNext30DaysCount: Int {
|
||||||
let calendar = Calendar.current
|
calculatedStats.next30DaysCount
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,30 +537,30 @@ struct LargeWidgetStatsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
// Total Tasks
|
// Overdue
|
||||||
StatItem(
|
StatItem(
|
||||||
value: entry.taskCount,
|
value: entry.overdueCount,
|
||||||
label: "Total",
|
label: "Overdue",
|
||||||
color: .blue
|
color: entry.overdueCount > 0 ? .red : .secondary
|
||||||
)
|
)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.frame(height: 30)
|
.frame(height: 30)
|
||||||
|
|
||||||
// Due This Week
|
// Next 7 Days (exclusive of overdue)
|
||||||
StatItem(
|
StatItem(
|
||||||
value: entry.dueThisWeekCount,
|
value: entry.dueNext7DaysCount,
|
||||||
label: "This Week",
|
label: "7 Days",
|
||||||
color: .orange
|
color: .orange
|
||||||
)
|
)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.frame(height: 30)
|
.frame(height: 30)
|
||||||
|
|
||||||
// Due Next 30 Days
|
// Next 30 Days (days 8-30)
|
||||||
StatItem(
|
StatItem(
|
||||||
value: entry.dueNext30DaysCount,
|
value: entry.dueNext30DaysCount,
|
||||||
label: "Next 30 Days",
|
label: "30 Days",
|
||||||
color: .green
|
color: .green
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ struct MainTabView: View {
|
|||||||
}
|
}
|
||||||
.id(refreshID)
|
.id(refreshID)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Pros", systemImage: "wrench.and.screwdriver.fill")
|
Label("Contractors", systemImage: "wrench.and.screwdriver.fill")
|
||||||
}
|
}
|
||||||
.tag(2)
|
.tag(2)
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab)
|
||||||
|
|||||||
@@ -204,78 +204,32 @@ private struct ResidencesContent: View {
|
|||||||
return tasks
|
return tasks
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute total summary from task data using date logic
|
/// Compute total summary from task data using shared TaskStatsCalculator
|
||||||
private var computedSummary: TotalSummary {
|
private var computedSummary: TotalSummary {
|
||||||
let calendar = Calendar.current
|
let dueDates = activeTasks.map { $0.effectiveDueDate }
|
||||||
let today = calendar.startOfDay(for: Date())
|
let stats = TaskStatsCalculator.calculate(from: dueDates)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return TotalSummary(
|
return TotalSummary(
|
||||||
totalResidences: Int32(residences.count),
|
totalResidences: Int32(residences.count),
|
||||||
totalTasks: Int32(activeTasks.count),
|
totalTasks: Int32(activeTasks.count),
|
||||||
totalPending: 0,
|
totalPending: 0,
|
||||||
totalOverdue: overdueCount,
|
totalOverdue: Int32(stats.overdueCount),
|
||||||
tasksDueNextWeek: dueThisWeekCount,
|
tasksDueNextWeek: Int32(stats.next7DaysCount),
|
||||||
tasksDueNextMonth: dueNext30DaysCount
|
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 {
|
private func taskStats(for residenceId: Int32) -> ResidenceTaskStats {
|
||||||
let residenceTasks = activeTasks.filter { $0.residenceId == residenceId }
|
let residenceTasks = activeTasks.filter { $0.residenceId == residenceId }
|
||||||
let calendar = Calendar.current
|
let dueDates = residenceTasks.map { $0.effectiveDueDate }
|
||||||
let today = calendar.startOfDay(for: Date())
|
let stats = TaskStatsCalculator.calculate(from: dueDates)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResidenceTaskStats(
|
return ResidenceTaskStats(
|
||||||
totalCount: residenceTasks.count,
|
totalCount: residenceTasks.count,
|
||||||
overdueCount: overdueCount,
|
overdueCount: stats.overdueCount,
|
||||||
dueThisWeekCount: dueThisWeekCount,
|
dueThisWeekCount: stats.next7DaysCount,
|
||||||
dueNext30DaysCount: dueNext30DaysCount
|
dueNext30DaysCount: stats.next30DaysCount
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
97
iosApp/iosApp/Shared/TaskStatsCalculator.swift
Normal file
97
iosApp/iosApp/Shared/TaskStatsCalculator.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,10 +114,10 @@ struct ResidenceCard: View {
|
|||||||
color: taskStats.overdueCount > 0 ? Color.appError : Color.appTextSecondary
|
color: taskStats.overdueCount > 0 ? Color.appError : Color.appTextSecondary
|
||||||
)
|
)
|
||||||
|
|
||||||
// Due This Week
|
// Due Next 7 Days
|
||||||
TaskStatItem(
|
TaskStatItem(
|
||||||
value: taskStats.dueThisWeekCount,
|
value: taskStats.dueThisWeekCount,
|
||||||
label: "This Week",
|
label: "7 Days",
|
||||||
color: Color.appAccent
|
color: Color.appAccent
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -178,11 +178,6 @@ private struct PropertyIconView: View {
|
|||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: 48, height: 48)
|
.frame(width: 48, height: 48)
|
||||||
.foregroundColor(Color.appTextOnPrimary)
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
|
||||||
// Pulse ring for overdue
|
|
||||||
if hasOverdue {
|
|
||||||
PulseRing()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.naturalShadow(.subtle)
|
.naturalShadow(.subtle)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ struct SummaryCard: View {
|
|||||||
TimelineStatPill(
|
TimelineStatPill(
|
||||||
icon: "clock.fill",
|
icon: "clock.fill",
|
||||||
value: "\(summary.tasksDueNextWeek)",
|
value: "\(summary.tasksDueNextWeek)",
|
||||||
label: "Due This Week",
|
label: "Next 7 Days",
|
||||||
color: Color.appAccent
|
color: Color.appAccent
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user