import SwiftUI struct SentinelView: View { @State private var store = AppStore.shared @State private var analytics: VelocitySentinelLiveAnalyticsDTO? @State private var analyticsError: String? @State private var isAnalyticsLoading = false var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { header if let error = store.errorMessage { errorBanner(error) } availabilityCard analyticsCards sentimentCard journeyCard postureCards timelineCard } .padding(24) } .background(VelocityTheme.background) .scrollContentBackground(.hidden) .task { await store.refresh() await loadAnalytics() } .refreshable { await store.refresh() await loadAnalytics() } } private var header: some View { VStack(alignment: .leading, spacing: 4) { Text(SentinelScope.productFamilyName.uppercased()) .font(.system(size: 10, weight: .semibold)) .tracking(1.2) .foregroundStyle(VelocityTheme.mutedFg) Text(SentinelScope.navigationTitle) .font(.system(size: 28, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) Text("Live showroom perception analytics from the production Sentinel websocket and persisted perception intelligence.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } } private var availabilityCard: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("Production Scope") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Spacer() statusBadge( label: SentinelScope.availabilityBadge, color: VelocityTheme.success ) } Text("This iPad build reads `/api/sentinel/analytics/live`, which summarizes the real `/api/sentinel/ws/perception` stream after biometric packets are persisted by the backend.") .font(.system(size: 14)) .foregroundStyle(VelocityTheme.foreground) Text("Live-backed capabilities: \(SentinelScope.liveBackedSummary).") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) if let analyticsError { Text(analyticsError) .font(.system(size: 12, weight: .medium)) .foregroundStyle(VelocityTheme.danger) } else if isAnalyticsLoading { Text("Loading live perception analytics...") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } else if let analytics { Text("Stream: \(analytics.liveStreamPath)") .font(.system(size: 11, weight: .medium)) .foregroundStyle(VelocityTheme.accent) } } .padding(20) .glassCard(cornerRadius: 18) } private var analyticsCards: some View { HStack(spacing: 14) { SentinelCard( title: "Active visitors", value: "\(analytics?.activeSessions ?? 0)", subtitle: "Open Sentinel perception sessions", color: VelocityTheme.accent ) SentinelCard( title: "Visitors 24h", value: "\(analytics?.visitorCount24h ?? 0)", subtitle: "Sessions started in the last day", color: VelocityTheme.success ) SentinelCard( title: "Avg QD", value: String(format: "%.0f", analytics?.averageQdScore ?? 0), subtitle: "Average finalized session score", color: VelocityTheme.warning ) } } private var sentimentCard: some View { let distribution = analytics?.sentimentDistribution ?? [:] return VStack(alignment: .leading, spacing: 14) { Text("Sentiment Distribution") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) HStack(spacing: 12) { sentimentPill("Positive", distribution["positive"] ?? 0, VelocityTheme.success) sentimentPill("Neutral", distribution["neutral"] ?? 0, VelocityTheme.accent) sentimentPill("Negative", distribution["negative"] ?? 0, VelocityTheme.danger) } } .padding(20) .glassCard(cornerRadius: 18) } private var journeyCard: some View { VStack(alignment: .leading, spacing: 14) { HStack { Text("Showroom Journey") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Spacer() Text("Live feed") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(VelocityTheme.success) } if analytics?.journey.isEmpty ?? true { Text("No perception journey events have been persisted yet.") .font(.system(size: 13)) .foregroundStyle(VelocityTheme.mutedFg) } else { ForEach(analytics?.journey.prefix(8) ?? []) { event in VStack(alignment: .leading, spacing: 6) { HStack { Text(event.eventType.replacingOccurrences(of: "_", with: " ").capitalized) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Spacer() Text(String(format: "%.0f%%", event.engagementScore * 100)) .font(.system(size: 11, weight: .bold)) .foregroundStyle(event.engagementScore >= 0.7 ? VelocityTheme.success : VelocityTheme.accent) } Text(event.sceneLabel?.trimmedNonEmpty ?? event.sessionRef ?? "No scene label") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) Text(event.happenedAt ?? "Timestamp pending") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 14) .fill(VelocityTheme.surface) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(VelocityTheme.borderSubtle, lineWidth: 1) ) ) } } } .padding(20) .glassCard(cornerRadius: 18) } private var postureCards: some View { HStack(spacing: 14) { SentinelCard( title: "Pending insights", value: "\(store.metrics.pendingInsights)", subtitle: "Recommendations waiting on operator review", color: VelocityTheme.danger ) SentinelCard( title: "Transcript queue", value: "\(store.metrics.pendingTranscriptions)", subtitle: "Imported recordings still processing", color: VelocityTheme.warning ) SentinelCard( title: "Upcoming 24h", value: "\(store.alertSnapshot?.upcomingCalendarEvents24h ?? 0)", subtitle: "Calendar events due soon", color: VelocityTheme.success ) } } private var timelineCard: some View { VStack(alignment: .leading, spacing: 14) { HStack { Text("Recent Operator Timeline") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Spacer() if let lastRefresh = store.lastRefreshAt { Text("Updated \(lastRefresh.relativeShort)") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) } } if store.timelineEvents.isEmpty { Text("No live communication events have been loaded for the current high-priority leads yet.") .font(.system(size: 13)) .foregroundStyle(VelocityTheme.mutedFg) } else { ForEach(store.timelineEvents.prefix(6)) { item in VStack(alignment: .leading, spacing: 6) { HStack { Text(item.leadName) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Spacer() Text(item.event.channel.replacingOccurrences(of: "_", with: " ").capitalized) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(VelocityTheme.accent) } Text(item.event.summary ?? "No summary available for this event.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) Text(item.event.timestampDate?.relativeShort ?? item.event.timestamp) .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 14) .fill(VelocityTheme.surface) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(VelocityTheme.borderSubtle, lineWidth: 1) ) ) } } } .padding(20) .glassCard(cornerRadius: 18) } private func statusBadge(label: String, color: Color) -> some View { Text(label) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(color) .padding(.horizontal, 10) .padding(.vertical, 6) .background( Capsule() .fill(color.opacity(0.12)) .overlay(Capsule().stroke(color.opacity(0.22), lineWidth: 1)) ) } private func errorBanner(_ message: String) -> some View { Text(message) .font(.system(size: 13, weight: .medium)) .foregroundStyle(VelocityTheme.danger) .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 14) .fill(VelocityTheme.danger.opacity(0.10)) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1) ) ) } private func sentimentPill(_ label: String, _ value: Int, _ color: Color) -> some View { VStack(alignment: .leading, spacing: 5) { Text(label.uppercased()) .font(.system(size: 10, weight: .semibold)) .tracking(1) .foregroundStyle(VelocityTheme.mutedFg) Text("\(value)") .font(.system(size: 22, weight: .bold)) .foregroundStyle(color) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 14) .fill(VelocityTheme.surface) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(color.opacity(0.22), lineWidth: 1) ) ) } private func loadAnalytics(silent: Bool = false) async { if !silent { await MainActor.run { isAnalyticsLoading = true analyticsError = nil } } do { let response = try await VelocityAPIClient.shared.fetchSentinelLiveAnalytics() await MainActor.run { analytics = response analyticsError = nil isAnalyticsLoading = false } } catch { await MainActor.run { if let apiError = error as? VelocityAPIError, apiError.statusCode == 404 { analyticsError = "Sentinel analytics is not available on the configured backend yet." } else { analyticsError = error.localizedDescription } isAnalyticsLoading = false } } } } private struct SentinelCard: View { let title: String let value: String let subtitle: String let color: Color var body: some View { VStack(alignment: .leading, spacing: 10) { Text(title.uppercased()) .font(.system(size: 10, weight: .semibold)) .tracking(1) .foregroundStyle(VelocityTheme.mutedFg) Text(value) .font(.system(size: 26, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) Text(subtitle) .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) RoundedRectangle(cornerRadius: 4) .fill(color) .frame(width: 48, height: 4) } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) .glassCard(cornerRadius: 16) } } #Preview { SentinelView() } private extension String { var trimmedNonEmpty: String? { let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } }