Fix task stats consistency and improve residence card UI

- Compute task stats locally from kanban data for both summary card and residence cards
- Filter out completed_tasks and cancelled_tasks columns from counts
- Use startOfDay for accurate date comparisons (overdue, due this week, next 30 days)
- Add parseDate helper to DateUtils
- Make address tappable to open in Apple Maps
- Remove navigation title from residences list
- Update CLAUDE.md with Go backend references and DataManager architecture

🤖 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-16 23:57:01 -06:00
parent 7f3ed69574
commit c3a9494b0f
6 changed files with 378 additions and 354 deletions

View File

@@ -177,6 +177,16 @@ enum DateUtils {
return date < today
}
/// Parse a date string (YYYY-MM-DD or ISO datetime) into a Date object
static func parseDate(_ dateString: String?) -> Date? {
guard let dateString = dateString, !dateString.isEmpty else { return nil }
// Extract date part if it includes time
let datePart = dateString.components(separatedBy: "T").first ?? dateString
return isoDateFormatter.date(from: datePart)
}
// MARK: - Timezone Conversion Utilities
/// Convert a local hour (0-23) to UTC hour

View File

@@ -43,18 +43,6 @@
"comment" : "A message displayed when a contractor is successfully imported to the user's contacts. The placeholder is replaced with the name of the imported contractor.",
"isCommentAutoGenerated" : true
},
"%@, %@" : {
"comment" : "A city and state combination.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@, %2$@"
}
}
}
},
"%@, %@ %@" : {
"comment" : "A label displaying the city, state, and postal code of the residence.",
"isCommentAutoGenerated" : true,
@@ -17483,9 +17471,6 @@
},
"or" : {
},
"Overdue" : {
},
"Overview" : {
"comment" : "The title of the overview card.",

View File

@@ -24,8 +24,8 @@ struct ResidencesListView: View {
errorMessage: viewModel.errorMessage,
content: { residences in
ResidencesContent(
summary: viewModel.totalSummary ?? TotalSummary(totalResidences: Int32(residences.count), totalTasks: 0, totalPending: 0, totalOverdue: 0, tasksDueNextWeek: 0, tasksDueNextMonth: 0),
residences: residences
residences: residences,
tasksResponse: taskViewModel.tasksResponse
)
},
emptyContent: {
@@ -46,7 +46,6 @@ struct ResidencesListView: View {
})
}
}
.navigationTitle(L10n.Residences.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
@@ -171,49 +170,132 @@ 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 summary: TotalSummary
let residences: [ResidenceResponse]
let tasksResponse: TaskColumnsResponse?
/// 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 date logic
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
}
}
return TotalSummary(
totalResidences: Int32(residences.count),
totalTasks: Int32(activeTasks.count),
totalPending: 0,
totalOverdue: overdueCount,
tasksDueNextWeek: dueThisWeekCount,
tasksDueNextMonth: dueNext30DaysCount
)
}
/// Get task stats for a specific residence
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
}
}
return ResidenceTaskStats(
totalCount: residenceTasks.count,
overdueCount: overdueCount,
dueThisWeekCount: dueThisWeekCount,
dueNext30DaysCount: dueNext30DaysCount
)
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) {
// Summary Card with enhanced styling
SummaryCard(summary: summary)
SummaryCard(summary: computedSummary)
.padding(.horizontal, 16)
.padding(.top, 8)
// Properties Section Header
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 4) {
Text(L10n.Residences.yourProperties)
.font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text("\(residences.count) \(residences.count == 1 ? L10n.Residences.property : L10n.Residences.properties)")
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundColor(Color.appTextSecondary)
}
Spacer()
// Decorative leaf
Image(systemName: "leaf.fill")
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color.appPrimary.opacity(0.3))
.rotationEffect(.degrees(-15))
}
.padding(.horizontal, 20)
.padding(.top, 8)
// Residences List with staggered animation
LazyVStack(spacing: 16) {
ForEach(Array(residences.enumerated()), id: \.element.id) { index, residence in
NavigationLink(destination: ResidenceDetailView(residenceId: residence.id)) {
ResidenceCard(residence: residence)
.padding(.horizontal, 16)
ResidenceCard(
residence: residence,
taskStats: taskStats(for: residence.id)
)
.padding(.horizontal, 16)
}
.buttonStyle(OrganicCardButtonStyle())
.transition(.asymmetric(

View File

@@ -3,15 +3,36 @@ import ComposeApp
struct ResidenceCard: View {
let residence: ResidenceResponse
let taskStats: ResidenceTaskStats
/// Check if this residence has any overdue tasks
private var hasOverdueTasks: Bool {
Int(residence.overdueCount) > 0
taskStats.overdueCount > 0
}
/// Get task summary categories (max 3)
private var displayCategories: [TaskCategorySummary] {
Array(residence.taskSummary.categories.prefix(3))
/// Open the address in Apple Maps
private func openInMaps() {
var addressComponents: [String] = []
if !residence.streetAddress.isEmpty {
addressComponents.append(residence.streetAddress)
}
if !residence.city.isEmpty {
addressComponents.append(residence.city)
}
if !residence.stateProvince.isEmpty {
addressComponents.append(residence.stateProvince)
}
if !residence.postalCode.isEmpty {
addressComponents.append(residence.postalCode)
}
let address = addressComponents.joined(separator: ", ")
guard let encodedAddress = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "maps://?address=\(encodedAddress)") else {
return
}
UIApplication.shared.open(url)
}
var body: some View {
@@ -37,17 +58,25 @@ struct ResidenceCard: View {
.tracking(1.2)
}
// Address
// Address - tappable to open maps
if !residence.streetAddress.isEmpty {
HStack(spacing: 6) {
Image(systemName: "mappin")
.font(.system(size: 10, weight: .bold))
.foregroundColor(Color.appPrimary.opacity(0.7))
Button(action: {
openInMaps()
}) {
HStack(spacing: 6) {
Image(systemName: "mappin")
.font(.system(size: 10, weight: .bold))
.foregroundColor(Color.appPrimary.opacity(0.7))
Text(residence.streetAddress)
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.lineLimit(1)
Text(residence.streetAddress)
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appPrimary)
.lineLimit(1)
Image(systemName: "arrow.up.right")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(Color.appPrimary.opacity(0.6))
}
}
.padding(.top, 2)
}
@@ -64,41 +93,43 @@ struct ResidenceCard: View {
.padding(.top, OrganicSpacing.cozy)
.padding(.bottom, 16)
// Location Details (if available)
if !residence.city.isEmpty || !residence.stateProvince.isEmpty {
HStack(spacing: 8) {
Image(systemName: "location.fill")
.font(.system(size: 10, weight: .medium))
.foregroundColor(Color.appTextSecondary.opacity(0.6))
Text("\(residence.city), \(residence.stateProvince)")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary.opacity(0.8))
}
.padding(.horizontal, OrganicSpacing.cozy)
.padding(.bottom, 16)
}
// Divider
OrganicDivider()
.padding(.horizontal, 16)
// Task Stats Section
if !displayCategories.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(displayCategories, id: \.name) { category in
TaskCategoryChip(category: category)
}
if taskStats.totalCount > 0 {
HStack(spacing: 0) {
// Total Tasks
TaskStatItem(
value: taskStats.totalCount,
label: "Tasks",
color: Color.appPrimary
)
// Show overdue count if any
if hasOverdueTasks {
OverdueChip(count: Int(residence.overdueCount))
}
}
.padding(.horizontal, OrganicSpacing.cozy)
.padding(.vertical, 16)
// Overdue
TaskStatItem(
value: taskStats.overdueCount,
label: "Overdue",
color: taskStats.overdueCount > 0 ? Color.appError : Color.appTextSecondary
)
// Due This Week
TaskStatItem(
value: taskStats.dueThisWeekCount,
label: "This Week",
color: Color.appAccent
)
// Next 30 Days
TaskStatItem(
value: taskStats.dueNext30DaysCount,
label: "30 Days",
color: Color.appPrimary.opacity(0.7)
)
}
.padding(.horizontal, OrganicSpacing.cozy)
.padding(.vertical, 14)
} else {
// Empty state for tasks
HStack(spacing: 8) {
@@ -127,41 +158,25 @@ private struct PropertyIconView: View {
var body: some View {
ZStack {
// Background circle with gradient
Circle()
// Theme-colored background
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(
RadialGradient(
LinearGradient(
colors: [
Color.appPrimary,
Color.appPrimary.opacity(0.85)
],
center: .topLeading,
startRadius: 0,
endRadius: 52
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 52, height: 52)
// Inner highlight
Circle()
.fill(
RadialGradient(
colors: [
Color.white.opacity(0.25),
Color.clear
],
center: .topLeading,
startRadius: 0,
endRadius: 26
)
)
.frame(width: 52, height: 52)
.frame(width: 56, height: 56)
// House icon
Image("house_outline")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
.frame(width: 48, height: 48)
.foregroundColor(Color.appTextOnPrimary)
// Pulse ring for overdue
@@ -214,90 +229,24 @@ private struct PrimaryBadgeView: View {
}
}
// MARK: - Task Category Chip
// MARK: - Task Stat Item
private struct TaskCategoryChip: View {
let category: TaskCategorySummary
private var chipColor: Color {
Color(hex: category.color) ?? Color.appPrimary
}
private struct TaskStatItem: View {
let value: Int
let label: String
let color: Color
var body: some View {
HStack(spacing: 6) {
// Icon background
ZStack {
Circle()
.fill(chipColor.opacity(0.15))
.frame(width: 26, height: 26)
HStack(spacing: 4) {
Text("\(value)")
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundColor(color)
Image(systemName: category.icons.ios)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(chipColor)
}
// Count and label
VStack(alignment: .leading, spacing: 0) {
Text("\(category.count)")
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text(category.displayName)
.font(.system(size: 9, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.lineLimit(1)
}
Text(label)
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
Capsule()
.fill(Color.appBackgroundPrimary.opacity(0.6))
.overlay(
Capsule()
.stroke(chipColor.opacity(0.2), lineWidth: 1)
)
)
}
}
// MARK: - Overdue Chip
private struct OverdueChip: View {
let count: Int
var body: some View {
HStack(spacing: 6) {
ZStack {
Circle()
.fill(Color.appError.opacity(0.15))
.frame(width: 26, height: 26)
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(Color.appError)
}
VStack(alignment: .leading, spacing: 0) {
Text("\(count)")
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundColor(Color.appError)
Text("Overdue")
.font(.system(size: 9, weight: .medium))
.foregroundColor(Color.appError.opacity(0.8))
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
Capsule()
.fill(Color.appError.opacity(0.08))
.overlay(
Capsule()
.stroke(Color.appError.opacity(0.25), lineWidth: 1)
)
)
.frame(maxWidth: .infinity)
}
}
@@ -341,63 +290,69 @@ private struct CardBackgroundView: View {
#Preview("Residence Card") {
ScrollView {
VStack(spacing: 20) {
ResidenceCard(residence: ResidenceResponse(
id: 1,
ownerId: 1,
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"),
users: [],
name: "Sunset Villa",
propertyTypeId: 1,
propertyType: ResidenceType(id: 1, name: "House"),
streetAddress: "742 Evergreen Terrace",
apartmentUnit: "",
city: "San Francisco",
stateProvince: "CA",
postalCode: "94102",
country: "USA",
bedrooms: 4,
bathrooms: 2.5,
squareFootage: 2400,
lotSize: 0.35,
yearBuilt: 2018,
description: "Beautiful modern home",
purchaseDate: nil,
purchasePrice: nil,
isPrimary: true,
isActive: true,
overdueCount: 2,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z"
))
ResidenceCard(
residence: ResidenceResponse(
id: 1,
ownerId: 1,
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"),
users: [],
name: "Sunset Villa",
propertyTypeId: 1,
propertyType: ResidenceType(id: 1, name: "House"),
streetAddress: "742 Evergreen Terrace",
apartmentUnit: "",
city: "San Francisco",
stateProvince: "CA",
postalCode: "94102",
country: "USA",
bedrooms: 4,
bathrooms: 2.5,
squareFootage: 2400,
lotSize: 0.35,
yearBuilt: 2018,
description: "Beautiful modern home",
purchaseDate: nil,
purchasePrice: nil,
isPrimary: true,
isActive: true,
overdueCount: 2,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z"
),
taskStats: ResidenceTaskStats(totalCount: 8, overdueCount: 2, dueThisWeekCount: 3, dueNext30DaysCount: 2)
)
ResidenceCard(residence: ResidenceResponse(
id: 2,
ownerId: 1,
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"),
users: [],
name: "Downtown Loft",
propertyTypeId: 2,
propertyType: ResidenceType(id: 2, name: "Apartment"),
streetAddress: "100 Market Street, Unit 502",
apartmentUnit: "502",
city: "San Francisco",
stateProvince: "CA",
postalCode: "94105",
country: "USA",
bedrooms: 2,
bathrooms: 1.0,
squareFootage: 1100,
lotSize: nil,
yearBuilt: 2020,
description: "",
purchaseDate: nil,
purchasePrice: nil,
isPrimary: false,
isActive: true,
overdueCount: 0,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z"
))
ResidenceCard(
residence: ResidenceResponse(
id: 2,
ownerId: 1,
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"),
users: [],
name: "Downtown Loft",
propertyTypeId: 2,
propertyType: ResidenceType(id: 2, name: "Apartment"),
streetAddress: "100 Market Street, Unit 502",
apartmentUnit: "502",
city: "San Francisco",
stateProvince: "CA",
postalCode: "94105",
country: "USA",
bedrooms: 2,
bathrooms: 1.0,
squareFootage: 1100,
lotSize: nil,
yearBuilt: 2020,
description: "",
purchaseDate: nil,
purchasePrice: nil,
isPrimary: false,
isActive: true,
overdueCount: 0,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z"
),
taskStats: ResidenceTaskStats(totalCount: 0, overdueCount: 0, dueThisWeekCount: 0, dueNext30DaysCount: 0)
)
}
.padding(.horizontal, 16)
.padding(.vertical, 24)

View File

@@ -7,44 +7,14 @@ struct SummaryCard: View {
var body: some View {
VStack(spacing: 0) {
// Header with greeting
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 4) {
Text(greetingText)
.font(.system(size: 14, weight: .medium, design: .rounded))
.foregroundColor(Color.appTextSecondary)
Text("Your Home Dashboard")
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
}
Spacer()
// Decorative icon
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05)
],
center: .center,
startRadius: 0,
endRadius: 28
)
)
.frame(width: 56, height: 56)
Image(systemName: "house.lodge.fill")
.font(.system(size: 24, weight: .medium))
.foregroundColor(Color.appPrimary)
}
}
.padding(.horizontal, OrganicSpacing.cozy)
.padding(.top, OrganicSpacing.cozy)
.padding(.bottom, 20)
// Header
Text("Your Home Dashboard")
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, OrganicSpacing.cozy)
.padding(.top, OrganicSpacing.cozy)
.padding(.bottom, 20)
// Main Stats Row
HStack(spacing: 0) {
@@ -85,16 +55,16 @@ struct SummaryCard: View {
)
TimelineStatPill(
icon: "calendar.badge.clock",
icon: "clock.fill",
value: "\(summary.tasksDueNextWeek)",
label: "This Week",
label: "Due This Week",
color: Color.appAccent
)
TimelineStatPill(
icon: "calendar",
icon: "arrow.forward.circle.fill",
value: "\(summary.tasksDueNextMonth)",
label: "30 Days",
label: "Next 30 Days",
color: Color.appPrimary.opacity(0.7)
)
}
@@ -107,19 +77,6 @@ struct SummaryCard: View {
.naturalShadow(.pronounced)
}
private var greetingText: String {
let hour = Calendar.current.component(.hour, from: Date())
switch hour {
case 5..<12:
return "Good morning"
case 12..<17:
return "Good afternoon"
case 17..<21:
return "Good evening"
default:
return "Good night"
}
}
}
// MARK: - Organic Stat Item
@@ -167,32 +124,32 @@ private struct TimelineStatPill: View {
var isAlert: Bool = false
var body: some View {
VStack(spacing: 6) {
HStack(spacing: 4) {
VStack(spacing: 8) {
HStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: 11, weight: .semibold))
.font(.system(size: 17, weight: .semibold))
.foregroundColor(color)
Text(value)
.font(.system(size: 16, weight: .bold, design: .rounded))
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundColor(isAlert ? color : Color.appTextPrimary)
}
Text(label)
.font(.system(size: 10, weight: .medium))
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.padding(.vertical, 18)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(
isAlert
? color.opacity(0.08)
: Color.appBackgroundPrimary.opacity(0.5)
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(
isAlert ? color.opacity(0.2) : Color.clear,
lineWidth: 1