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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-12 00:05:11 -05:00
parent b8360a2e86
commit 7689027bdd
9 changed files with 280 additions and 11 deletions

View File

@@ -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<MonthlyCompletionSummary> = emptyList()
)
/**
* Monthly completion breakdown by kanban column.
*/
@Serializable
data class MonthlyCompletionSummary(
val month: String, // "2025-04" format
val completions: List<ColumnCompletionCount> = 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

View File

@@ -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)"
}
}

View File

@@ -84,7 +84,7 @@
1C87A0C42EDB8ED40081E450 /* HoneyDueUITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HoneyDueUITests.xctestplan; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
/* 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;

View File

@@ -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" : {
},

View File

@@ -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)
}

View File

@@ -354,6 +354,7 @@ class ResidenceViewModel: ObservableObject {
isPrimary: false,
isActive: true,
overdueCount: 0,
completionSummary: nil,
createdAt: now,
updatedAt: now
)

View File

@@ -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..<maxRows).reversed(), id: \.self) { row in
HStack(spacing: hexSpacing) {
ForEach(Array(summary.months.enumerated()), id: \.offset) { index, month in
let hexColors = buildHexColors(for: month)
if row < hexColors.count {
HexagonShape()
.fill(hexColors[row])
.frame(width: hexWidth, height: hexHeight)
} else if row < maxRows {
// Empty hex
HexagonShape()
.fill(Color.appTextSecondary.opacity(0.08))
.frame(width: hexWidth, height: hexHeight)
}
}
}
.frame(height: rowHeight)
}
// Overflow indicators
HStack(spacing: hexSpacing) {
ForEach(Array(summary.months.enumerated()), id: \.offset) { index, month in
if month.overflow > 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..<min(Int(entry.count), maxRows - colors.count) {
colors.append(color)
}
}
if colors.count >= 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
}
}

View File

@@ -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"
))

View File

@@ -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"
),