Operations Hub — iOS Implementation Spec
The web CRM already has two Operations Hub views consolidating all outreach intelligence for a market into a 7-tab surface. The iOS app has the foundation (InboundCommandView, MarketSweepCommandView, InboundService, FieldRoutes) — all connected to the same Supabase. This spec describes the **reorganization + two new hub views** needed to match the web architecture. This is not a ground-up build.
Full Public Reader
# Operations Hub — iOS Implementation Spec
Date: 2026-05-28
Status: Ready to implement — pass to Codex
Relates to: Web `PDXSEAOperationsHub.tsx`, `MiamiOperationsHub.tsx`
Supabase project: `wovknbcvxjvayfdflxkn`
---
Context
The web CRM already has two Operations Hub views consolidating all outreach intelligence for a market into a 7-tab surface. The iOS app has the foundation (InboundCommandView, MarketSweepCommandView, InboundService, FieldRoutes) — all connected to the same Supabase. This spec describes the reorganization + two new hub views needed to match the web architecture. This is not a ground-up build.
Real lead counts (verified 2026-05-28 via SQL):
- OR+WA (`inbound_leads` where state IN ('OR','Oregon','WA','Washington')): 1,052 leads, 100
- FL (`inbound_leads` where state IN ('FL','Florida')): 700 leads, 100
- All markets total: 5,270 leads, 100
- All `status = 'new'`, zero contacted — full pipeline untouched
---
What Currently Exists in iOS
| Component | File | Status |
|---|---|---|
| Inbound lead board | `Views/Growth/InboundCommandView.swift` | ✅ Built — flat list, market program selector |
| Market sweep command | `Views/Growth/MarketSweepCommandView.swift` | ✅ Built — 7 modes: Sweeps/Markets/Prospects/Outreach/Responses/Sequences/Inbound |
| Response inbox | `Views/Growth/ResponseInboxView.swift` | ✅ Built |
| Sequence manager | `Views/Growth/SequenceManagerView.swift` | ✅ Built |
| Market detail | `Views/Growth/MarketDetailView.swift` | ✅ Built |
| Inbound service | `Services/InboundService.swift` | ✅ Built — Supabase bridge for `inbound_leads` |
| Field routes + map | `Views/FieldRoutes/` | ✅ Built — cluster map, visit outcomes, day summary |
| Visits | `Views/Visits/` | ✅ Built — check-in, revisit list |
What's missing: Metro/region-grouped contact queues for OR+WA and FL, and a unified hub entry point per market combining map + Instagram + sequences + templates + showcase in one place.
---
Files to Create
Views/Growth/
├── OperationsHubView.swift ← Generic hub container + tab router
├── PDXSEAHubView.swift ← PDX/SEA config instantiation
├── MiamiHubView.swift ← Miami/SFL config instantiation
├── HubContactQueueView.swift ← Region → City → Leads grouped list
├── HubInstagramQueueView.swift ← IG-only leads list
├── HubLocationsView.swift ← Raw locations table with enrichment links
└── HubTemplatesView.swift ← Template viewer (reads outreach_templates)Files to modify:
- `Services/InboundService.swift` — add 3 new fetch methods
- `Views/Growth/MarketSweepCommandView.swift` — add hub callout cards + mode routing
---
Data Models
// MARK: - Hub Config Models
struct OperationsHubConfig {
let title: String
let emoji: String
let states: [String] // ["OR", "Oregon", "WA", "Washington"] — handle both abbrev and full
let regions: [HubRegion]
let defaultMarket: String // "portland" | "south-florida"
let accentColor: Color
let leadCountLabel: String // "1,052 leads"
}
struct HubRegion: Identifiable {
let id: String // "portland-metro", "seattle-metro", etc.
let key: String
let label: String
let emoji: String
let cities: [String] // used for city-level grouping
}
// MARK: - Grouped Display Models
struct GroupedRegion: Identifiable {
let id: String
let region: HubRegion
let leads: [InboundLeadWithScore]
let cities: [CityGroup]
var isExpanded: Bool = true
}
struct CityGroup: Identifiable {
let id: String
let city: String
let leads: [InboundLeadWithScore]
var isExpanded: Bool = false
}
// MARK: - Hub Tab
enum HubTab: String, CaseIterable, Identifiable {
case contactQueue = "Contact Queue"
case map = "Map"
case instagram = "Instagram"
case sequences = "Sequences"
case templates = "Templates"
case showcase = "Showcase"
case allLocations = "All Locations"
var id: String { rawValue }
var icon: String {
switch self {
case .contactQueue: return "list.bullet.clipboard"
case .map: return "map"
case .instagram: return "camera"
case .sequences: return "arrow.triangle.2.circlepath"
case .templates: return "doc.text"
case .showcase: return "chart.bar"
case .allLocations: return "building.2"
}
}
}---
InboundService.swift — Additions
Add these three methods to the existing `InboundService` class:
// MARK: - Hub Fetch Methods
/// Fetches all inbound_leads for a set of states, handling both abbreviated and full-name variants.
/// Uses pagination (.range) to bypass Supabase's 1000-row default cap.
func fetchLeadsForStates(_ states: [String]) async -> [InboundLeadWithScore] {
var allLeads: [InboundLeadWithScore] = []
let pageSize = 1000
var from = 0
repeat {
let batch: [InboundLead] = (try? await supabase
.from("inbound_leads")
.select("*")
.in("state", values: states)
.range(from: from, to: from + pageSize - 1)
.execute()
.value) ?? []
allLeads.append(contentsOf: batch.map { InboundLeadWithScore(lead: $0, score: nil) })
from += pageSize
if batch.count < pageSize { break }
} while true
return allLeads
}
/// Fetches raw locations for a set of states (for All Locations tab).
func fetchLocationsForStates(_ states: [String]) async -> [LocationRow] {
var allLocations: [LocationRow] = []
let pageSize = 1000
var from = 0
repeat {
let batch: [LocationRow] = (try? await supabase
.from("locations")
.select("id, name, city, state, address, contact_email, instagram, phone, google_rating, is_partner")
.in("state", values: states)
.range(from: from, to: from + pageSize - 1)
.execute()
.value) ?? []
allLocations.append(contentsOf: batch)
from += pageSize
if batch.count < pageSize { break }
} while true
return allLocations
}
/// Fetches leads that have an Instagram handle (for Instagram tab).
func fetchInstagramLeads(states: [String]) async -> [InboundLeadWithScore] {
let allLeads = await fetchLeadsForStates(states)
return allLeads.filter { $0.lead.instagram != nil && !($0.lead.instagram?.isEmpty ?? true) }
}---
OperationsHubView.swift
import SwiftUI
struct OperationsHubView: View {
let config: OperationsHubConfig
@State private var selectedTab: HubTab = .contactQueue
@State private var leads: [InboundLeadWithScore] = []
@State private var locations: [LocationRow] = []
@State private var isLoading = true
@StateObject private var inboundService = InboundService()
var body: some View {
VStack(spacing: 0) {
// Header
hubHeader
// Tab bar
hubTabBar
// Content
TabView(selection: $selectedTab) {
HubContactQueueView(config: config, leads: leads)
.tag(HubTab.contactQueue)
HubMapView(config: config, leads: leads)
.tag(HubTab.map)
HubInstagramQueueView(config: config, leads: leads.filter {
$0.lead.instagram != nil
})
.tag(HubTab.instagram)
SequenceManagerView()
.tag(HubTab.sequences)
HubTemplatesView(config: config)
.tag(HubTab.templates)
MarketDetailView(market: config.defaultMarket)
.tag(HubTab.showcase)
HubLocationsView(config: config, locations: locations)
.tag(HubTab.allLocations)
}
.tabViewStyle(.page(indexDisplayMode: .never))
}
.navigationBarHidden(true)
.task { await loadData() }
}
private var hubHeader: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("\(config.emoji) \(config.title)")
.font(.title2.bold())
Text(config.leadCountLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if isLoading {
ProgressView().scaleEffect(0.8)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemBackground))
}
private var hubTabBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
ForEach(HubTab.allCases) { tab in
Button {
withAnimation(.easeInOut(duration: 0.2)) {
selectedTab = tab
}
} label: {
HStack(spacing: 4) {
Image(systemName: tab.icon)
.font(.caption)
Text(tab.rawValue)
.font(.caption.weight(.medium))
}
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(
selectedTab == tab
? config.accentColor
: Color(.secondarySystemBackground)
)
.foregroundStyle(
selectedTab == tab ? .white : .primary
)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
.background(Color(.systemBackground))
.overlay(alignment: .bottom) {
Divider()
}
}
private func loadData() async {
isLoading = true
async let leadsTask = inboundService.fetchLeadsForStates(config.states)
async let locationsTask = inboundService.fetchLocationsForStates(config.states)
let (fetchedLeads, fetchedLocations) = await (leadsTask, locationsTask)
leads = fetchedLeads
locations = fetchedLocations
isLoading = false
}
}---
HubContactQueueView.swift
import SwiftUI
struct HubContactQueueView: View {
let config: OperationsHubConfig
let leads: [InboundLeadWithScore]
@State private var statusFilter: LeadStatus? = nil
@State private var groupedRegions: [GroupedRegion] = []
@State private var expandedRegions: Set<String> = []
@State private var expandedCities: Set<String> = []
var body: some View {
VStack(spacing: 0) {
filterBar
if groupedRegions.isEmpty {
emptyState
} else {
leadList
}
}
.onChange(of: leads) { _ in buildGroups() }
.onChange(of: statusFilter) { _ in buildGroups() }
.onAppear { buildGroups() }
}
private var filterBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(label: "All", isSelected: statusFilter == nil) {
statusFilter = nil
}
ForEach([LeadStatus.new, .contacted, .interested, .closed], id: \.self) { status in
FilterChip(label: status.rawValue, isSelected: statusFilter == status) {
statusFilter = statusFilter == status ? nil : status
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
}
private var leadList: some View {
List {
ForEach(groupedRegions) { group in
Section {
if expandedRegions.contains(group.id) {
ForEach(group.cities) { cityGroup in
CityRowView(
cityGroup: cityGroup,
isExpanded: expandedCities.contains(cityGroup.id),
onToggle: {
toggleCity(cityGroup.id)
}
)
}
}
} header: {
RegionHeaderView(
region: group.region,
leadCount: group.leads.count,
newCount: group.leads.filter { $0.lead.status == "new" }.count,
igCount: group.leads.filter { $0.lead.instagram != nil }.count,
isExpanded: expandedRegions.contains(group.id),
accentColor: config.accentColor,
onToggle: { toggleRegion(group.id) }
)
}
}
}
.listStyle(.plain)
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "tray")
.font(.system(size: 40))
.foregroundStyle(.tertiary)
Text("No leads match this filter")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func buildGroups() {
let filtered = statusFilter == nil
? leads
: leads.filter { $0.lead.status == statusFilter?.rawValue }
groupedRegions = config.regions.compactMap { region in
let regionLeads = filtered.filter { lead in
let city = lead.lead.city ?? ""
return region.cities.contains(city)
}
guard !regionLeads.isEmpty else { return nil }
let byCity = Dictionary(grouping: regionLeads) { $0.lead.city ?? "Unknown" }
let cities = byCity.map { CityGroup(id: $0.key, city: $0.key, leads: $0.value) }
.sorted { $0.leads.count > $1.leads.count }
return GroupedRegion(id: region.id, region: region, leads: regionLeads, cities: cities)
}
// Auto-expand all regions on first load
if expandedRegions.isEmpty {
expandedRegions = Set(groupedRegions.map { $0.id })
}
}
private func toggleRegion(_ id: String) {
if expandedRegions.contains(id) {
expandedRegions.remove(id)
} else {
expandedRegions.insert(id)
}
}
private func toggleCity(_ id: String) {
if expandedCities.contains(id) {
expandedCities.remove(id)
} else {
expandedCities.insert(id)
}
}
}
// MARK: - Supporting Views
struct RegionHeaderView: View {
let region: HubRegion
let leadCount: Int
let newCount: Int
let igCount: Int
let isExpanded: Bool
let accentColor: Color
let onToggle: () -> Void
var body: some View {
Button(action: onToggle) {
HStack {
Text(region.emoji)
Text(region.label)
.font(.subheadline.bold())
.foregroundStyle(.primary)
Spacer()
HStack(spacing: 6) {
StatBadge(value: leadCount, label: "total", color: .secondary)
StatBadge(value: newCount, label: "new", color: accentColor)
StatBadge(value: igCount, label: "IG", color: .purple)
}
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
.buttonStyle(.plain)
}
}
struct CityRowView: View {
let cityGroup: CityGroup
let isExpanded: Bool
let onToggle: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button(action: onToggle) {
HStack {
Text(cityGroup.city)
.font(.subheadline)
Spacer()
Text("\(cityGroup.leads.count)")
.font(.caption)
.foregroundStyle(.secondary)
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding(.vertical, 8)
.padding(.horizontal, 12)
}
.buttonStyle(.plain)
if isExpanded {
ForEach(cityGroup.leads, id: \.lead.id) { lead in
NavigationLink(destination: InboundLeadDetailSheet(lead: lead.lead)) {
LeadRowView(lead: lead.lead)
}
.padding(.leading, 12)
}
}
}
}
}
struct LeadRowView: View {
let lead: InboundLead
var body: some View {
HStack(spacing: 10) {
// Priority dot
Circle()
.fill(priorityColor)
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(lead.businessName ?? "Unknown")
.font(.subheadline)
.lineLimit(1)
HStack(spacing: 6) {
if lead.instagram != nil {
Label("IG", systemImage: "camera")
.font(.caption2)
.foregroundStyle(.purple)
}
Text(lead.email ?? "")
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
StatusBadge(status: lead.status ?? "new")
}
.padding(.vertical, 6)
}
private var priorityColor: Color {
switch lead.priorityScore ?? 0 {
case 80...: return .red
case 60..<80: return .orange
case 40..<60: return .yellow
default: return .gray
}
}
}
struct StatBadge: View {
let value: Int
let label: String
let color: Color
var body: some View {
Text("\(value) \(label)")
.font(.caption2)
.foregroundStyle(color)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
}
struct FilterChip: View {
let label: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? Color.accentColor : Color(.secondarySystemBackground))
.foregroundStyle(isSelected ? .white : .primary)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}---
HubInstagramQueueView.swift
import SwiftUI
struct HubInstagramQueueView: View {
let config: OperationsHubConfig
let leads: [InboundLeadWithScore]
var body: some View {
List(leads, id: \.lead.id) { item in
HStack(spacing: 12) {
Image(systemName: "camera.fill")
.foregroundStyle(.purple)
.frame(width: 32, height: 32)
.background(Color.purple.opacity(0.12))
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(item.lead.businessName ?? "Unknown")
.font(.subheadline.weight(.medium))
Text("@\(item.lead.instagram ?? "")")
.font(.caption)
.foregroundStyle(.purple)
Text(item.lead.city ?? "")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
// Email status
if item.lead.email != nil {
Label("Email", systemImage: "envelope.fill")
.font(.caption2)
.foregroundStyle(.green)
}
// Open in Instagram
Button {
openInstagram(handle: item.lead.instagram ?? "")
} label: {
Text("Open")
.font(.caption.weight(.medium))
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Color.purple.opacity(0.15))
.foregroundStyle(.purple)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
.listStyle(.plain)
.navigationTitle("Instagram (\(leads.count))")
}
private func openInstagram(handle: String) {
let appURL = URL(string: "instagram://user?username=\(handle)")!
let webURL = URL(string: "https://instagram.com/\(handle)")!
if UIApplication.shared.canOpenURL(appURL) {
UIApplication.shared.open(appURL)
} else {
UIApplication.shared.open(webURL)
}
}
}---
HubLocationsView.swift
import SwiftUI
struct HubLocationsView: View {
let config: OperationsHubConfig
let locations: [LocationRow]
@State private var searchText = ""
private var filtered: [LocationRow] {
guard !searchText.isEmpty else { return locations }
return locations.filter {
($0.name ?? "").localizedCaseInsensitiveContains(searchText) ||
($0.city ?? "").localizedCaseInsensitiveContains(searchText)
}
}
var body: some View {
VStack(spacing: 0) {
SearchBar(text: $searchText, placeholder: "Search locations...")
List(filtered, id: \.id) { location in
LocationRowView(location: location)
}
.listStyle(.plain)
}
.navigationTitle("All Locations (\(locations.count))")
}
}
struct LocationRowView: View {
let location: LocationRow
var body: some View {
HStack(spacing: 10) {
// Email badge
Circle()
.fill(location.contactEmail != nil ? Color.green : Color.orange)
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(location.name ?? "Unknown")
.font(.subheadline)
.lineLimit(1)
Text(location.city ?? "")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
HStack(spacing: 8) {
if location.instagram != nil {
Image(systemName: "camera")
.font(.caption)
.foregroundStyle(.purple)
}
if location.isPartner == true {
Text("Partner")
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.12))
.foregroundStyle(.blue)
.clipShape(Capsule())
}
}
}
.padding(.vertical, 4)
}
}---
HubMapView.swift
Reuses existing `MapKit` infrastructure. Create a thin adapter that loads `inbound_leads` for the hub's states and plots them as `MKAnnotation` pins on Apple Maps.
import SwiftUI
import MapKit
struct HubMapView: View {
let config: OperationsHubConfig
let leads: [InboundLeadWithScore]
@State private var region = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 45.5, longitude: -122.6), // default Portland
span: MKCoordinateSpan(latitudeDelta: 2.0, longitudeDelta: 2.0)
)
@State private var selectedLead: InboundLead? = nil
private var mappableLeads: [InboundLeadWithScore] {
leads.filter { $0.lead.latitude != nil && $0.lead.longitude != nil }
}
var body: some View {
ZStack(alignment: .bottom) {
Map(coordinateRegion: $region, annotationItems: mappableLeads) { item in
MapAnnotation(coordinate: CLLocationCoordinate2D(
latitude: item.lead.latitude ?? 0,
longitude: item.lead.longitude ?? 0
)) {
LeadMapPin(lead: item.lead) {
selectedLead = item.lead
}
}
}
.ignoresSafeArea(edges: .top)
// Stats bar
HStack(spacing: 16) {
MapStat(value: leads.count, label: "total", color: .primary)
MapStat(value: mappableLeads.count, label: "mapped", color: config.accentColor)
MapStat(value: leads.count - mappableLeads.count, label: "no coords", color: .secondary)
}
.padding()
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding()
}
.sheet(item: $selectedLead) { lead in
InboundLeadDetailSheet(lead: lead)
}
.onAppear { centerMap() }
}
private func centerMap() {
// Center based on config states
if config.states.contains("OR") || config.states.contains("Oregon") {
region.center = CLLocationCoordinate2D(latitude: 45.9, longitude: -122.4)
} else if config.states.contains("FL") || config.states.contains("Florida") {
region.center = CLLocationCoordinate2D(latitude: 26.1, longitude: -80.2)
}
}
}
struct LeadMapPin: View {
let lead: InboundLead
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
Circle()
.fill(pinColor)
.frame(width: 10, height: 10)
.overlay(Circle().stroke(Color.white, lineWidth: 1.5))
}
.buttonStyle(.plain)
}
private var pinColor: Color {
switch lead.status {
case "contacted": return .blue
case "interested": return .green
case "closed": return .purple
default:
return lead.instagram != nil ? Color.orange : Color.gray
}
}
}
struct MapStat: View {
let value: Int
let label: String
let color: Color
var body: some View {
VStack(spacing: 2) {
Text("\(value)").font(.headline.bold()).foregroundStyle(color)
Text(label).font(.caption2).foregroundStyle(.secondary)
}
}
}---
PDXSEAHubView.swift
import SwiftUI
struct PDXSEAHubView: View {
static let config = OperationsHubConfig(
title: "Portland + Seattle",
emoji: "🌲⛰️",
states: ["OR", "Oregon", "WA", "Washington"],
regions: [
HubRegion(
id: "portland-metro",
key: "portland",
label: "Portland Metro",
emoji: "🌲",
cities: ["Portland", "Beaverton", "Hillsboro", "Lake Oswego", "Eugene", "Bend",
"Gresham", "Tigard", "Tualatin", "Milwaukie", "Oregon City"]
),
HubRegion(
id: "seattle-metro",
key: "seattle",
label: "Seattle Metro",
emoji: "⛰️",
cities: ["Seattle", "Tacoma", "Bellevue", "Redmond", "Kirkland", "Spokane",
"Renton", "Bothell", "Shoreline", "Edmonds", "Burien", "Kent",
"Federal Way", "Lynnwood", "Issaquah", "Sammamish"]
)
],
defaultMarket: "portland",
accentColor: .green,
leadCountLabel: "1,052 leads"
)
var body: some View {
OperationsHubView(config: Self.config)
}
}---
MiamiHubView.swift
import SwiftUI
struct MiamiHubView: View {
static let config = OperationsHubConfig(
title: "Miami + South Florida",
emoji: "🌴☀️",
states: ["FL", "Florida"],
regions: [
HubRegion(
id: "miami",
key: "miami",
label: "Miami",
emoji: "🌴",
cities: ["Miami", "Brickell", "Wynwood", "Coconut Grove", "Coral Gables",
"Design District", "Downtown Miami", "Little Havana", "Midtown",
"North Miami", "Miami Beach", "South Beach", "Mid-Beach", "Surfside", "Bal Harbour"]
),
HubRegion(
id: "fort-lauderdale",
key: "fort-lauderdale",
label: "Fort Lauderdale",
emoji: "⛵",
cities: ["Fort Lauderdale", "Wilton Manors", "Hollywood", "Pompano Beach",
"Coral Springs", "Deerfield Beach", "Hallandale Beach"]
),
HubRegion(
id: "boca-delray",
key: "boca-delray",
label: "Boca / Delray",
emoji: "☀️",
cities: ["Boca Raton", "Delray Beach", "Boynton Beach"]
),
HubRegion(
id: "west-palm",
key: "west-palm",
label: "West Palm",
emoji: "🌊",
cities: ["West Palm Beach", "Palm Beach Gardens", "Jupiter", "Lake Worth",
"Riviera Beach", "Wellington"]
),
HubRegion(
id: "naples",
key: "naples",
label: "Naples",
emoji: "🌅",
cities: ["Naples", "Marco Island", "Immokalee"]
),
HubRegion(
id: "swfl",
key: "swfl",
label: "Southwest Florida",
emoji: "🦩",
cities: ["Fort Myers", "Bonita Springs", "Estero", "Cape Coral",
"Sanibel", "Lehigh Acres"]
)
],
defaultMarket: "south-florida",
accentColor: .orange,
leadCountLabel: "700 leads"
)
var body: some View {
OperationsHubView(config: Self.config)
}
}---
HubTemplatesView.swift
import SwiftUI
struct OutreachTemplate: Identifiable, Decodable {
let id: String
let name: String
let subject: String?
let body: String?
let channel: String? // "email", "instagram", "sms"
let market: String?
}
struct HubTemplatesView: View {
let config: OperationsHubConfig
@State private var templates: [OutreachTemplate] = []
@State private var isLoading = true
var body: some View {
Group {
if isLoading {
ProgressView("Loading templates...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if templates.isEmpty {
VStack(spacing: 12) {
Image(systemName: "doc.text")
.font(.system(size: 40))
.foregroundStyle(.tertiary)
Text("No templates yet")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List(templates) { template in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(template.name)
.font(.subheadline.weight(.medium))
Spacer()
if let channel = template.channel {
Text(channel)
.font(.caption2)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.accentColor.opacity(0.12))
.foregroundStyle(.accentColor)
.clipShape(Capsule())
}
}
if let subject = template.subject {
Text("Subject: \(subject)")
.font(.caption)
.foregroundStyle(.secondary)
}
if let body = template.body {
Text(body)
.font(.caption)
.foregroundStyle(.tertiary)
.lineLimit(2)
}
}
.padding(.vertical, 4)
}
.listStyle(.plain)
}
}
.task {
await loadTemplates()
}
}
private func loadTemplates() async {
isLoading = true
// Fetch from outreach_templates table — filter by market if possible
templates = (try? await SupabaseClient.shared
.from("outreach_templates")
.select("*")
.execute()
.value) ?? []
isLoading = false
}
}---
MarketSweepCommandView.swift — Entry Point Changes
Add two hub callout cards above the mode picker. Find the section where `GrowthMode` cases are listed and add:
// In GrowthMode enum — add two new cases:
case pdxseaHub = "PDX/SEA Hub"
case miamiHub = "Miami Hub"
// In MarketSweepCommandView, above the mode picker ScrollView, add:
VStack(spacing: 12) {
Text("Operations Hubs")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
HStack(spacing: 12) {
HubCalloutCard(
emoji: "🌲⛰️",
title: "Portland + Seattle",
subtitle: "1,052 leads",
accentColor: .green
) {
selectedMode = .pdxseaHub
}
HubCalloutCard(
emoji: "🌴☀️",
title: "Miami + SFL",
subtitle: "700 leads",
accentColor: .orange
) {
selectedMode = .miamiHub
}
}
.padding(.horizontal, 16)
}
.padding(.top, 12)
// HubCalloutCard component:
struct HubCalloutCard: View {
let emoji: String
let title: String
let subtitle: String
let accentColor: Color
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(alignment: .leading, spacing: 6) {
Text(emoji)
.font(.title2)
Text(title)
.font(.subheadline.bold())
.foregroundStyle(.primary)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(accentColor.opacity(0.08))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(accentColor.opacity(0.3), lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
// In the mode switch/content area, add cases:
case .pdxseaHub:
PDXSEAHubView()
case .miamiHub:
MiamiHubView()---
Key Constraints
1. No outreach sending from iOS. The mark-as-contacted button updates `inbound_leads.status = 'contacted'` in Supabase only. No email sends, no DMs. This is a tracking + planning surface.
2. Supabase 1000-row cap. All fetch methods must use `.range()` pagination loops. DO NOT use `.limit(1000)` as a ceiling — loop until batch < pageSize.
3. State normalization. The `inbound_leads` table has both abbreviated (`OR`, `WA`, `FL`) and full-name (`Oregon`, `Washington`, `Florida`) state values. Always query with both forms: `IN ('OR','Oregon')`.
4. Reuse existing components. `SequenceManagerView`, `MarketDetailView`, `InboundLeadDetailSheet`, `InboundService` all exist and work — wrap, don't rewrite.
---
Implementation Order
1. `InboundService` — add `fetchLeadsForStates`, `fetchLocationsForStates`, `fetchInstagramLeads`
2. Data models — `OperationsHubConfig`, `HubRegion`, `HubTab`, `GroupedRegion`, `CityGroup`
3. `OperationsHubView` — generic container + tab bar + data loading
4. `HubContactQueueView` — region grouping logic + lead rows (this is the most complex)
5. `PDXSEAHubView` + `MiamiHubView` — config declarations
6. `HubMapView` — MapKit adapter (reuse existing infrastructure)
7. `HubInstagramQueueView` — simple list with deep link
8. `HubLocationsView` — simple filtered list
9. `HubTemplatesView` — reads `outreach_templates` table
10. `MarketSweepCommandView` — callout cards + mode routing (entry point, last)
Promotion Decision
Attach run IDs, datasets, metrics, and reproduction commands.
Source Anchor
Milk Men/Documentation/Implementation/OPERATIONS-HUB-IOS-SPEC.md
Detected Structure
Method · Evaluation · Code Anchors · Architecture