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:
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -354,6 +354,7 @@ class ResidenceViewModel: ObservableObject {
|
||||
isPrimary: false,
|
||||
isActive: true,
|
||||
overdueCount: 0,
|
||||
completionSummary: nil,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
)
|
||||
|
||||
206
iosApp/iosApp/Shared/Components/HoneycombCompletionGrid.swift
Normal file
206
iosApp/iosApp/Shared/Components/HoneycombCompletionGrid.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
))
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user