From 7689027bdd94d9d2effc8b2688e2d24b3469c89c Mon Sep 17 00:00:00 2001 From: Trey t Date: Thu, 12 Mar 2026 00:05:11 -0500 Subject: [PATCH] Add honeycomb completion heatmap and dev API environment - Add HoneycombSummaryView component with colored hexagon grid - Display completion summary on residence detail screen - Add CompletionSummary model to KMP shared layer - Add DEV/PROD environment split in ApiConfig - Fix ResidenceResponse initializers for completionSummary parameter Co-Authored-By: Claude Opus 4.6 --- .../com/tt/honeyDue/models/Residence.kt | 33 +++ .../com/tt/honeyDue/network/ApiConfig.kt | 17 +- iosApp/honeyDue.xcodeproj/project.pbxproj | 10 +- iosApp/iosApp/Localizable.xcstrings | 12 + .../Residence/ResidenceDetailView.swift | 9 + .../iosApp/Residence/ResidenceViewModel.swift | 1 + .../Components/HoneycombCompletionGrid.swift | 206 ++++++++++++++++++ .../Residence/PropertyHeaderCard.swift | 1 + .../Subviews/Residence/ResidenceCard.swift | 2 + 9 files changed, 280 insertions(+), 11 deletions(-) create mode 100644 iosApp/iosApp/Shared/Components/HoneycombCompletionGrid.swift diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Residence.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Residence.kt index 51eb19d..5024d63 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Residence.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Residence.kt @@ -52,6 +52,7 @@ data class ResidenceResponse( @SerialName("is_primary") val isPrimary: Boolean = false, @SerialName("is_active") val isActive: Boolean = true, @SerialName("overdue_count") val overdueCount: Int = 0, + @SerialName("completion_summary") val completionSummary: CompletionSummary? = null, @SerialName("created_at") val createdAt: String, @SerialName("updated_at") val updatedAt: String ) { @@ -261,6 +262,38 @@ data class RemoveUserResponse( val message: String ) +/** + * Completion summary for honeycomb grid display. + * Returned by the residence detail endpoint. + */ +@Serializable +data class CompletionSummary( + @SerialName("total_all_time") val totalAllTime: Int = 0, + @SerialName("total_last_12_months") val totalLast12Months: Int = 0, + val months: List = emptyList() +) + +/** + * Monthly completion breakdown by kanban column. + */ +@Serializable +data class MonthlyCompletionSummary( + val month: String, // "2025-04" format + val completions: List = emptyList(), + val total: Int = 0, + val overflow: Int = 0 +) + +/** + * Count of completions from a specific kanban column with its color. + */ +@Serializable +data class ColumnCompletionCount( + val column: String, + val color: String, + val count: Int +) + // Type aliases for backwards compatibility with existing code typealias Residence = ResidenceResponse typealias ResidenceShareCode = ShareCodeResponse diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt index 0aa0da6..f058d29 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt @@ -3,9 +3,10 @@ package com.tt.honeyDue.network /** * API Environment Configuration * - * To switch between localhost and dev server, simply change the CURRENT_ENV value: + * To switch environments, simply change the CURRENT_ENV value: * - Environment.LOCAL for local development - * - Environment.DEV for remote dev server + * - Environment.DEV for remote dev/staging server + * - Environment.PROD for production server */ object ApiConfig { // ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️ @@ -13,7 +14,8 @@ object ApiConfig { enum class Environment { LOCAL, - DEV + DEV, + PROD } /** @@ -22,7 +24,8 @@ object ApiConfig { fun getBaseUrl(): String { return when (CURRENT_ENV) { Environment.LOCAL -> "http://${getLocalhostAddress()}:8000/api" - Environment.DEV -> "https://api.myhoneydue.com/api" + Environment.DEV -> "https://devapi.myhoneydue.com/api" + Environment.PROD -> "https://api.myhoneydue.com/api" } } @@ -32,7 +35,8 @@ object ApiConfig { fun getMediaBaseUrl(): String { return when (CURRENT_ENV) { Environment.LOCAL -> "http://${getLocalhostAddress()}:8000" - Environment.DEV -> "https://api.myhoneydue.com" + Environment.DEV -> "https://devapi.myhoneydue.com" + Environment.PROD -> "https://api.myhoneydue.com" } } @@ -42,7 +46,8 @@ object ApiConfig { fun getEnvironmentName(): String { return when (CURRENT_ENV) { Environment.LOCAL -> "Local (${getLocalhostAddress()}:8000)" - Environment.DEV -> "Dev Server (api.myhoneydue.com)" + Environment.DEV -> "Dev Server (devapi.myhoneydue.com)" + Environment.PROD -> "Production (api.myhoneydue.com)" } } diff --git a/iosApp/honeyDue.xcodeproj/project.pbxproj b/iosApp/honeyDue.xcodeproj/project.pbxproj index ceeedfb..6ea01c6 100644 --- a/iosApp/honeyDue.xcodeproj/project.pbxproj +++ b/iosApp/honeyDue.xcodeproj/project.pbxproj @@ -84,7 +84,7 @@ 1C87A0C42EDB8ED40081E450 /* HoneyDueUITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HoneyDueUITests.xctestplan; sourceTree = ""; }; 1CBF1BED2ECD9768001BF56C /* HoneyDueUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HoneyDueUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 4B07E04F794A4C1CAA8CCD5D /* PhotoViewerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoViewerSheet.swift; sourceTree = ""; }; - 96A3DDC05E14B3F83E56282F /* HoneyDue.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HoneyDue.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 96A3DDC05E14B3F83E56282F /* honeyDue.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = honeyDue.app; sourceTree = BUILT_PRODUCTS_DIR; }; AD6CD907CA1045CBBC845D91 /* CompletionCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionCardView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -308,7 +308,7 @@ FA6022B7B844191C54E57EB4 /* Products */ = { isa = PBXGroup; children = ( - 96A3DDC05E14B3F83E56282F /* HoneyDue.app */, + 96A3DDC05E14B3F83E56282F /* honeyDue.app */, 1C07893D2EBC218B00392B46 /* HoneyDueExtension.appex */, 1C685CD22EC5539000A9669B /* HoneyDueTests.xctest */, 1CBF1BED2ECD9768001BF56C /* HoneyDueUITests.xctest */, @@ -458,7 +458,7 @@ 1C81F38F2EE69AF1000739EA /* PostHog */, ); productName = honeyDue; - productReference = 96A3DDC05E14B3F83E56282F /* HoneyDue.app */; + productReference = 96A3DDC05E14B3F83E56282F /* honeyDue.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -699,6 +699,7 @@ 1C0789552EBC218D00392B46 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + APP_GROUP_IDENTIFIER = group.com.tt.honeyDue.dev; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = HoneyDueExtension.entitlements; @@ -719,7 +720,6 @@ ); MARKETING_VERSION = 1.0; OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION"; - APP_GROUP_IDENTIFIER = group.com.tt.honeyDue.dev; PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev.HoneyDueExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -735,6 +735,7 @@ 1C0789562EBC218D00392B46 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + APP_GROUP_IDENTIFIER = group.com.tt.honeyDue; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = HoneyDueExtension.entitlements; @@ -755,7 +756,6 @@ ); MARKETING_VERSION = 1.0; OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION"; - APP_GROUP_IDENTIFIER = group.com.tt.honeyDue; PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.HoneyDueExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index f7555f8..9bd2c54 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -62,6 +62,10 @@ "comment" : "A badge displaying the number of tasks in a category. The argument is the count of tasks in the category.", "isCommentAutoGenerated" : true }, + "%d in the last 12 months" : { + "comment" : "A small label below the main number that explains that the number is for the last 12 months.", + "isCommentAutoGenerated" : true + }, "%d tasks" : { "comment" : "A label showing the number of tasks a contractor has. The argument is the number of tasks.", "isCommentAutoGenerated" : true @@ -104,6 +108,10 @@ }, "• 10K+ homeowners" : { + }, + "+%d" : { + "comment" : "A small label inside a hexagon that indicates how many more tasks were planned for a given month but were not completed. The text inside the label is the count of unaccomplished tasks for that month.", + "isCommentAutoGenerated" : true }, "+%lld" : { @@ -5334,6 +5342,10 @@ "comment" : "The title for the view that shows a user's photo submissions.", "isCommentAutoGenerated" : true }, + "completions at %@" : { + "comment" : "A subheading describing the content of the honeycomb view.", + "isCommentAutoGenerated" : true + }, "CONFIRM PASSWORD" : { }, diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift index 2dba804..41a7545 100644 --- a/iosApp/iosApp/Residence/ResidenceDetailView.swift +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -237,6 +237,15 @@ private extension ResidenceDetailView { contractorsSection .padding(.horizontal, 16) + + // Honeycomb completion summary + if let summary = residence.completionSummary { + HoneycombSummaryView( + summary: summary, + residenceName: residence.name + ) + .padding(.horizontal, 16) + } } .padding(.bottom, OrganicSpacing.airy) } diff --git a/iosApp/iosApp/Residence/ResidenceViewModel.swift b/iosApp/iosApp/Residence/ResidenceViewModel.swift index e02893e..89cd236 100644 --- a/iosApp/iosApp/Residence/ResidenceViewModel.swift +++ b/iosApp/iosApp/Residence/ResidenceViewModel.swift @@ -354,6 +354,7 @@ class ResidenceViewModel: ObservableObject { isPrimary: false, isActive: true, overdueCount: 0, + completionSummary: nil, createdAt: now, updatedAt: now ) diff --git a/iosApp/iosApp/Shared/Components/HoneycombCompletionGrid.swift b/iosApp/iosApp/Shared/Components/HoneycombCompletionGrid.swift new file mode 100644 index 0000000..b7e9eb4 --- /dev/null +++ b/iosApp/iosApp/Shared/Components/HoneycombCompletionGrid.swift @@ -0,0 +1,206 @@ +import SwiftUI +import ComposeApp + +// MARK: - Honeycomb Summary View + +/// Displays a honeycomb grid of task completions over the last 12 months. +/// Each hexagon represents a completed task, colored by the kanban column +/// the task was in when completed. Empty slots show faint gray hexagons. +struct HoneycombSummaryView: View { + let summary: CompletionSummary + let residenceName: String + + private let maxRows = 10 + private let hexSize: CGFloat = 14 + private let hexSpacing: CGFloat = 2 + + var body: some View { + VStack(spacing: OrganicSpacing.comfortable) { + // Header with big number + headerSection + + // Honeycomb grid + honeycombGrid + .padding(.horizontal, 4) + + // Month labels + monthLabels + .padding(.horizontal, 4) + } + .padding(OrganicSpacing.comfortable) + .background( + OrganicCardBackground(showBlob: true, blobVariation: 2) + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .shadow(color: Color.black.opacity(0.06), radius: 8, x: 0, y: 2) + } + + // MARK: - Header + + private var headerSection: some View { + VStack(spacing: 8) { + Text("\(summary.totalAllTime)") + .font(.system(size: 56, weight: .thin, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + + Text("completions at \(residenceName)") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + + if summary.totalLast12Months != summary.totalAllTime { + Text("\(summary.totalLast12Months) in the last 12 months") + .font(.caption) + .foregroundColor(Color.appTextSecondary.opacity(0.7)) + } + } + } + + // MARK: - Grid + + private var honeycombGrid: some View { + GeometryReader { geo in + let columns = summary.months.count + guard columns > 0 else { return AnyView(EmptyView()) } + + let totalWidth = geo.size.width + let colWidth = totalWidth / CGFloat(columns) + // Recalculate hex size to fit available width + let effectiveHexSize = min(hexSize, (colWidth - hexSpacing) / 2) + let hexWidth = effectiveHexSize * 2 + let hexHeight = effectiveHexSize * sqrt(3) + let rowHeight = hexHeight + hexSpacing + + return AnyView( + VStack(spacing: 0) { + // Grid rows (top = row 10, bottom = row 1) + ForEach((0.. 0 { + Text("+\(month.overflow)") + .font(.system(size: 8, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .frame(width: hexWidth) + } else { + Color.clear + .frame(width: hexWidth, height: 10) + } + } + } + } + ) + } + .frame(height: gridHeight) + } + + private var gridHeight: CGFloat { + let hexHeight = hexSize * sqrt(3) + let rowHeight = hexHeight + hexSpacing + return rowHeight * CGFloat(maxRows) + 14 // +14 for overflow text + } + + // MARK: - Month Labels + + private var monthLabels: some View { + HStack(spacing: hexSpacing) { + ForEach(Array(summary.months.enumerated()), id: \.offset) { _, month in + Text(monthAbbreviation(from: month.month)) + .font(.system(size: 8, weight: .medium)) + .foregroundColor(Color.appTextSecondary.opacity(0.6)) + .frame(maxWidth: .infinity) + } + } + } + + // MARK: - Helpers + + /// Build an array of colors for the filled hexes in a month column. + /// Order: completed (green) first at bottom, then overdue (red), due_soon (orange), + /// in_progress (purple), upcoming (blue). + private func buildHexColors(for month: MonthlyCompletionSummary) -> [Color] { + let fillOrder = [ + "completed_tasks", + "overdue_tasks", + "due_soon_tasks", + "in_progress_tasks", + "upcoming_tasks" + ] + + var colors: [Color] = [] + let columnMap = Dictionary( + month.completions.map { ($0.column, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + for column in fillOrder { + if let entry = columnMap[column] { + let color = Color(hex: entry.color) ?? Color.green + for _ in 0..= maxRows { break } + } + + return colors + } + + private func monthAbbreviation(from monthString: String) -> String { + // "2025-04" → "Apr" + let parts = monthString.split(separator: "-") + guard parts.count == 2, let monthNum = Int(parts[1]) else { return "" } + let formatter = DateFormatter() + formatter.dateFormat = "MMM" + var components = DateComponents() + components.month = monthNum + if let date = Calendar.current.date(from: components) { + return formatter.string(from: date) + } + return "" + } +} + +// MARK: - Hexagon Shape + +/// A regular hexagon shape (pointy-top orientation). +struct HexagonShape: Shape { + func path(in rect: CGRect) -> Path { + let w = rect.width + let h = rect.height + let cx = rect.midX + let cy = rect.midY + + var path = Path() + // Pointy-top hexagon + path.move(to: CGPoint(x: cx, y: cy - h / 2)) + path.addLine(to: CGPoint(x: cx + w / 2, y: cy - h / 4)) + path.addLine(to: CGPoint(x: cx + w / 2, y: cy + h / 4)) + path.addLine(to: CGPoint(x: cx, y: cy + h / 2)) + path.addLine(to: CGPoint(x: cx - w / 2, y: cy + h / 4)) + path.addLine(to: CGPoint(x: cx - w / 2, y: cy - h / 4)) + path.closeSubpath() + return path + } +} + diff --git a/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift b/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift index 4af82e7..40c9bc9 100644 --- a/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift +++ b/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift @@ -314,6 +314,7 @@ private struct PropertyHeaderBackground: View { isPrimary: true, isActive: true, overdueCount: 0, + completionSummary: nil, createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z" )) diff --git a/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift b/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift index 1752f2a..8046b15 100644 --- a/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift +++ b/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift @@ -288,6 +288,7 @@ private struct CardBackgroundView: View { isPrimary: true, isActive: true, overdueCount: 2, + completionSummary: nil, createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z" ), @@ -320,6 +321,7 @@ private struct CardBackgroundView: View { isPrimary: false, isActive: true, overdueCount: 0, + completionSummary: nil, createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z" ),