     1→import SwiftUI
     2→
     3→// MARK: - Phase Timeline View
     4→
     5→/// Visual timeline showing completed, current, and pending phases
     6→struct PhaseTimelineView: View {
     7→    let progress: AnalysisProgress
     8→
     9→    var body: some View {
    10→        VStack(alignment: .leading, spacing: 12) {
    11→            // Header
    12→            HStack {
    13→                Text("Analysis Phases")
    14→                    .font(.subheadline)
    15→                    .fontWeight(.medium)
    16→                    .foregroundColor(.secondary)
    17→
    18→                Spacer()
    19→
    20→                Text("\(progress.completedPhases.count)/\(progress.totalPhases)")
    21→                    .font(.caption)
    22→                    .foregroundColor(.secondary)
    23→            }
    24→
    25→            // Timeline
    26→            VStack(spacing: 0) {
    27→                ForEach(Array(AnalysisProgress.allPhases.enumerated()), id: \.offset) { index, phase in
    28→                    PhaseRow(
    29→                        phase: phase,
    30→                        status: phaseStatus(for: phase),
    31→                        duration: progress.phaseDuration(for: phase),
    32→                        isLast: index == AnalysisProgress.allPhases.count - 1
    33→                    )
    34→                }
    35→            }
    36→        }
    37→        .padding()
    38→        .background(Color(.systemGray6))
    39→        .cornerRadius(12)
    40→    }
    41→
    42→    private func phaseStatus(for phase: AnalysisPhase) -> PhaseStatus {
    43→        if progress.completedPhases.contains(phase) {
    44→            return .completed
    45→        } else if progress.currentPhase == phase {
    46→            return .current
    47→        } else if phase == .complete || phase == .failed {
    48→            return .pending
    49→        } else {
    50→            // Check if phase comes before current
    51→            let allPhases = AnalysisProgress.allPhases
    52→            if let currentIndex = allPhases.firstIndex(of: progress.currentPhase),
    53→               let phaseIndex = allPhases.firstIndex(of: phase) {
    54→                return phaseIndex < currentIndex ? .completed : .pending
    55→            }
    56→            return .pending
    57→        }
    58→    }
    59→}
    60→
    61→// MARK: - Phase Status
    62→
    63→enum PhaseStatus {
    64→    case pending
    65→    case current
    66→    case completed
    67→
    68→    var icon: String {
    69→        switch self {
    70→        case .pending: return "circle"
    71→        case .current: return "circle.dotted"
    72→        case .completed: return "checkmark.circle.fill"
    73→        }
    74→    }
    75→
    76→    var color: Color {
    77→        switch self {
    78→        case .pending: return .gray
    79→        case .current: return .purple
    80→        case .completed: return .green
    81→        }
    82→    }
    83→}
    84→
    85→// MARK: - Phase Row
    86→
    87→struct PhaseRow: View {
    88→    let phase: AnalysisPhase
    89→    let status: PhaseStatus
    90→    let duration: TimeInterval?
    91→    let isLast: Bool
    92→
    93→    @State private var isAnimating = false
    94→
    95→    var body: some View {
    96→        HStack(spacing: 12) {
    97→            // Timeline indicator
    98→            VStack(spacing: 0) {
    99→                // Icon
   100→                ZStack {
   101→                    Circle()
   102→                        .fill(status.color.opacity(0.2))
   103→                        .frame(width: 28, height: 28)
   104→
   105→                    if status == .current {
   106→                        Circle()
   107→                            .stroke(status.color, lineWidth: 2)
   108→                            .frame(width: 28, height: 28)
   109→                            .scaleEffect(isAnimating ? 1.2 : 1.0)
   110→                            .opacity(isAnimating ? 0.5 : 1.0)
   111→                    }
   112→
   113→                    Image(systemName: status.icon)
   114→                        .font(.system(size: 12, weight: .medium))
   115→                        .foregroundColor(status.color)
   116→                }
   117→
   118→                // Connector line
   119→                if !isLast {
   120→                    Rectangle()
   121→                        .fill(status == .completed ? Color.green.opacity(0.5) : Color(.systemGray4))
   122→                        .frame(width: 2, height: 20)
   123→                }
   124→            }
   125→
   126→            // Phase info
   127→            VStack(alignment: .leading, spacing: 2) {
   128→                HStack {
   129→                    Image(systemName: phase.icon)
   130→                        .font(.caption)
   131→                        .foregroundColor(status.color)
   132→
   133→                    Text(phase.displayName)
   134→                        .font(.subheadline)
   135→                        .fontWeight(status == .current ? .semibold : .regular)
   136→                        .foregroundColor(status == .pending ? .secondary : .primary)
   137→
   138→                    Spacer()
   139→
   140→                    if let duration = duration {
   141→                        Text(formatDuration(duration))
   142→                            .font(.caption)
   143→                            .foregroundColor(.secondary)
   144→                    } else if status == .current {
   145→                        Text("In progress...")
   146→                            .font(.caption)
   147→                            .foregroundColor(.purple)
   148→                            .italic()
   149→                    }
   150→                }
   151→            }
   152→            .padding(.bottom, isLast ? 0 : 8)
   153→        }
   154→        .onAppear {
   155→            if status == .current {
   156→                withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
   157→                    isAnimating = true
   158→                }
   159→            }
   160→        }
   161→    }
   162→
   163→    private func formatDuration(_ duration: TimeInterval) -> String {
   164→        if duration < 1 {
   165→            return "<1s"
   166→        } else if duration < 60 {
   167→            return String(format: "%.1fs", duration)
   168→        } else {
   169→            let minutes = Int(duration / 60)
   170→            let seconds = Int(duration) % 60
   171→            return "\(minutes)m \(seconds)s"
   172→        }
   173→    }
   174→}
   175→
   176→// MARK: - Compact Phase Timeline
   177→
   178→/// Compact horizontal timeline for smaller spaces
   179→struct CompactPhaseTimelineView: View {
   180→    let progress: AnalysisProgress
   181→
   182→    var body: some View {
   183→        HStack(spacing: 4) {
   184→            ForEach(Array(AnalysisProgress.allPhases.enumerated()), id: \.offset) { index, phase in
   185→                CompactPhaseIndicator(
   186→                    phase: phase,
   187→                    isCompleted: progress.completedPhases.contains(phase),
   188→                    isCurrent: progress.currentPhase == phase
   189→                )
   190→
   191→                if index < AnalysisProgress.allPhases.count - 1 {
   192→                    Rectangle()
   193→                        .fill(
   194→                            progress.completedPhases.contains(phase)
   195→                                ? Color.green.opacity(0.5)
   196→                                : Color(.systemGray4)
   197→                        )
   198→                        .frame(height: 2)
   199→                        .frame(maxWidth: 20)
   200→                }
   201→            }
   202→        }
   203→    }
   204→}
   205→
   206→// MARK: - Compact Phase Indicator
   207→
   208→struct CompactPhaseIndicator: View {
   209→    let phase: AnalysisPhase
   210→    let isCompleted: Bool
   211→    let isCurrent: Bool
   212→
   213→    @State private var isAnimating = false
   214→
   215→    var body: some View {
   216→        ZStack {
   217→            Circle()
   218→                .fill(backgroundColor)
   219→                .frame(width: 24, height: 24)
   220→
   221→            if isCurrent {
   222→                Circle()
   223→                    .stroke(Color.purple, lineWidth: 2)
   224→                    .frame(width: 24, height: 24)
   225→                    .scaleEffect(isAnimating ? 1.3 : 1.0)
   226→                    .opacity(isAnimating ? 0 : 1)
   227→            }
   228→
   229→            Image(systemName: iconName)
   230→                .font(.system(size: 10))
   231→                .foregroundColor(iconColor)
   232→        }
   233→        .onAppear {
   234→            if isCurrent {
   235→                withAnimation(.easeOut(duration: 1.0).repeatForever(autoreverses: false)) {
   236→                    isAnimating = true
   237→                }
   238→            }
   239→        }
   240→    }
   241→
   242→    private var backgroundColor: Color {
   243→        if isCompleted {
   244→            return .green.opacity(0.2)
   245→        } else if isCurrent {
   246→            return .purple.opacity(0.2)
   247→        } else {
   248→            return Color(.systemGray5)
   249→        }
   250→    }
   251→
   252→    private var iconName: String {
   253→        if isCompleted {
   254→            return "checkmark"
   255→        } else if isCurrent {
   256→            return phase.icon
   257→        } else {
   258→            return "circle"
   259→        }
   260→    }
   261→
   262→    private var iconColor: Color {
   263→        if isCompleted {
   264→            return .green
   265→        } else if isCurrent {
   266→            return .purple
   267→        } else {
   268→            return .gray
   269→        }
   270→    }
   271→}
   272→
   273→// MARK: - Preview
   274→
   275→#Preview("Phase Timeline") {
   276→    VStack(spacing: 20) {
   277→        // Full timeline
   278→        PhaseTimelineView(
   279→            progress: AnalysisProgress(
   280→                currentPhase: .analyzingContent,
   281→                phaseStartTimes: [
   282→                    AnalysisPhase.loadingFiles.rawValue: Date().addingTimeInterval(-60),
   283→                    AnalysisPhase.parsingWikiLinks.rawValue: Date().addingTimeInterval(-45),
   284→                    AnalysisPhase.analyzingContent.rawValue: Date().addingTimeInterval(-30)
   285→                ],
   286→                phaseEndTimes: [
   287→                    AnalysisPhase.loadingFiles.rawValue: Date().addingTimeInterval(-45),
   288→                    AnalysisPhase.parsingWikiLinks.rawValue: Date().addingTimeInterval(-30)
   289→                ],
   290→                completedPhases: [.loadingFiles, .parsingWikiLinks]
   291→            )
   292→        )
   293→
   294→        Divider()
   295→
   296→        // Compact timeline
   297→        Text("Compact Timeline")
   298→            .font(.caption)
   299→            .foregroundColor(.secondary)
   300→
   301→        CompactPhaseTimelineView(
   302→            progress: AnalysisProgress(
   303→                currentPhase: .buildingGraph,
   304→                completedPhases: [.loadingFiles, .parsingWikiLinks, .analyzingContent]
   305→            )
   306→        )
   307→        .padding()
   308→    }
   309→    .padding()
   310→}
   311→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

import SwiftUI

// MARK: - Phase Timeline View

/// Visual timeline showing completed, current, and pending phases
struct PhaseTimelineView: View {
    let progress: AnalysisProgress

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            // Header
            HStack {
                Text("Analysis Phases")
                    .font(.subheadline)
                    .fontWeight(.medium)
                    .foregroundColor(.secondary)

                Spacer()

                Text("\(progress.completedPhases.count)/\(progress.totalPhases)")
                    .font(.caption)
                    .foregroundColor(.secondary)
            }

            // Timeline
            VStack(spacing: 0) {
                ForEach(Array(AnalysisProgress.allPhases.enumerated()), id: \.offset) { index, phase in
                    PhaseRow(
                        phase: phase,
                        status: phaseStatus(for: phase),
                        duration: progress.phaseDuration(for: phase),
                        isLast: index == AnalysisProgress.allPhases.count - 1
                    )
                }
            }
        }
        .padding()
        .background(Color(.systemGray6))
        .cornerRadius(12)
    }

    private func phaseStatus(for phase: AnalysisPhase) -> PhaseStatus {
        if progress.completedPhases.contains(phase) {
            return .completed
        } else if progress.currentPhase == phase {
            return .current
        } else if phase == .complete || phase == .failed {
            return .pending
        } else {
            // Check if phase comes before current
            let allPhases = AnalysisProgress.allPhases
            if let currentIndex = allPhases.firstIndex(of: progress.currentPhase),
               let phaseIndex = allPhases.firstIndex(of: phase) {
                return phaseIndex < currentIndex ? .completed : .pending
            }
            return .pending
        }
    }
}

// MARK: - Phase Status

enum PhaseStatus {
    case pending
    case current
    case completed

    var icon: String {
        switch self {
        case .pending: return "circle"
        case .current: return "circle.dotted"
        case .completed: return "checkmark.circle.fill"
        }
    }

    var color: Color {
        switch self {
        case .pending: return .gray
        case .current: return .purple
        case .completed: return .green
        }
    }
}

// MARK: - Phase Row

struct PhaseRow: View {
    let phase: AnalysisPhase
    let status: PhaseStatus
    let duration: TimeInterval?
    let isLast: Bool

    @State private var isAnimating = false

    var body: some View {
        HStack(spacing: 12) {
            // Timeline indicator
            VStack(spacing: 0) {
                // Icon
                ZStack {
                    Circle()
                        .fill(status.color.opacity(0.2))
                        .frame(width: 28, height: 28)

                    if status == .current {
                        Circle()
                            .stroke(status.color, lineWidth: 2)
                            .frame(width: 28, height: 28)
                            .scaleEffect(isAnimating ? 1.2 : 1.0)
                            .opacity(isAnimating ? 0.5 : 1.0)
                    }

                    Image(systemName: status.icon)
                        .font(.system(size: 12, weight: .medium))
                        .foregroundColor(status.color)
                }

                // Connector line
                if !isLast {
                    Rectangle()
                        .fill(status == .completed ? Color.green.opacity(0.5) : Color(.systemGray4))
                        .frame(width: 2, height: 20)
                }
            }

            // Phase info
            VStack(alignment: .leading, spacing: 2) {
                HStack {
                    Image(systemName: phase.icon)
                        .font(.caption)
                        .foregroundColor(status.color)

                    Text(phase.displayName)
                        .font(.subheadline)
                        .fontWeight(status == .current ? .semibold : .regular)
                        .foregroundColor(status == .pending ? .secondary : .primary)

                    Spacer()

                    if let duration = duration {
                        Text(formatDuration(duration))
                            .font(.caption)
                            .foregroundColor(.secondary)
                    } else if status == .current {
                        Text("In progress...")
                            .font(.caption)
                            .foregroundColor(.purple)
                            .italic()
                    }
                }
            }
            .padding(.bottom, isLast ? 0 : 8)
        }
        .onAppear {
            if status == .current {
                withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
                    isAnimating = true
                }
            }
        }
    }

    private func formatDuration(_ duration: TimeInterval) -> String {
        if duration < 1 {
            return "<1s"
        } else if duration < 60 {
            return String(format: "%.1fs", duration)
        } else {
            let minutes = Int(duration / 60)
            let seconds = Int(duration) % 60
            return "\(minutes)m \(seconds)s"
        }
    }
}

// MARK: - Compact Phase Timeline

/// Compact horizontal timeline for smaller spaces
struct CompactPhaseTimelineView: View {
    let progress: AnalysisProgress

    var body: some View {
        HStack(spacing: 4) {
            ForEach(Array(AnalysisProgress.allPhases.enumerated()), id: \.offset) { index, phase in
                CompactPhaseIndicator(
                    phase: phase,
                    isCompleted: progress.completedPhases.contains(phase),
                    isCurrent: progress.currentPhase == phase
                )

                if index < AnalysisProgress.allPhases.count - 1 {
                    Rectangle()
                        .fill(
                            progress.completedPhases.contains(phase)
                                ? Color.green.opacity(0.5)
                                : Color(.systemGray4)
                        )
                        .frame(height: 2)
                        .frame(maxWidth: 20)
                }
            }
        }
    }
}

// MARK: - Compact Phase Indicator

struct CompactPhaseIndicator: View {
    let phase: AnalysisPhase
    let isCompleted: Bool
    let isCurrent: Bool

    @State private var isAnimating = false

    var body: some View {
        ZStack {
            Circle()
                .fill(backgroundColor)
                .frame(width: 24, height: 24)

            if isCurrent {
                Circle()
                    .stroke(Color.purple, lineWidth: 2)
                    .frame(width: 24, height: 24)
                    .scaleEffect(isAnimating ? 1.3 : 1.0)
                    .opacity(isAnimating ? 0 : 1)
            }

            Image(systemName: iconName)
                .font(.system(size: 10))
                .foregroundColor(iconColor)
        }
        .onAppear {
            if isCurrent {
                withAnimation(.easeOut(duration: 1.0).repeatForever(autoreverses: false)) {
                    isAnimating = true
                }
            }
        }
    }

    private var backgroundColor: Color {
        if isCompleted {
            return .green.opacity(0.2)
        } else if isCurrent {
            return .purple.opacity(0.2)
        } else {
            return Color(.systemGray5)
        }
    }

    private var iconName: String {
        if isCompleted {
            return "checkmark"
        } else if isCurrent {
            return phase.icon
        } else {
            return "circle"
        }
    }

    private var iconColor: Color {
        if isCompleted {
            return .green
        } else if isCurrent {
            return .purple
        } else {
            return .gray
        }
    }
}

// MARK: - Preview

#Preview("Phase Timeline") {
    VStack(spacing: 20) {
        // Full timeline
        PhaseTimelineView(
            progress: AnalysisProgress(
                currentPhase: .analyzingContent,
                phaseStartTimes: [
                    AnalysisPhase.loadingFiles.rawValue: Date().addingTimeInterval(-60),
                    AnalysisPhase.parsingWikiLinks.rawValue: Date().addingTimeInterval(-45),
                    AnalysisPhase.analyzingContent.rawValue: Date().addingTimeInterval(-30)
                ],
                phaseEndTimes: [
                    AnalysisPhase.loadingFiles.rawValue: Date().addingTimeInterval(-45),
                    AnalysisPhase.parsingWikiLinks.rawValue: Date().addingTimeInterval(-30)
                ],
                completedPhases: [.loadingFiles, .parsingWikiLinks]
            )
        )

        Divider()

        // Compact timeline
        Text("Compact Timeline")
            .font(.caption)
            .foregroundColor(.secondary)

        CompactPhaseTimelineView(
            progress: AnalysisProgress(
                currentPhase: .buildingGraph,
                completedPhases: [.loadingFiles, .parsingWikiLinks, .analyzingContent]
            )
        )
        .padding()
    }
    .padding()
}


Now let me check the AnalysisProgress model to understand the data structure:


Bash

find /Users/neog/Apps/iOS/ObsidianViewer-iOS -type f -name "*AnalysisProgress*" -o -name "*Models*" | grep -v "DerivedData"


Let me check the ConnectionModels file:


     1→import Foundation
     2→import SwiftUI
     3→
     4→// MARK: - Connection Types
     5→
     6→/// Types of connections between notes
     7→enum ConnectionType: String, Codable, CaseIterable, Identifiable {
     8→    case semantic      // Similar topics/concepts (AI-detected)
     9→    case temporal      // Related by date/timeline
    10→    case hierarchical  // Parent-child relationships
    11→    case reference     // One note mentions concepts from another
    12→    case action        // Tasks that relate to other notes
    13→    case wikiLink      // Explicit [[wiki-link]]
    14→
    15→    var id: String { rawValue }
    16→
    17→    var icon: String {
    18→        switch self {
    19→        case .semantic: return "brain.head.profile"
    20→        case .temporal: return "calendar"
    21→        case .hierarchical: return "arrow.up.arrow.down"
    22→        case .reference: return "quote.bubble"
    23→        case .action: return "checklist"
    24→        case .wikiLink: return "link"
    25→        }
    26→    }
    27→
    28→    var color: Color {
    29→        switch self {
    30→        case .semantic: return .purple
    31→        case .temporal: return .blue
    32→        case .hierarchical: return .orange
    33→        case .reference: return .green
    34→        case .action: return .red
    35→        case .wikiLink: return .gray
    36→        }
    37→    }
    38→
    39→    var displayName: String {
    40→        switch self {
    41→        case .semantic: return "Semantic"
    42→        case .temporal: return "Temporal"
    43→        case .hierarchical: return "Hierarchical"
    44→        case .reference: return "Reference"
    45→        case .action: return "Action"
    46→        case .wikiLink: return "Wiki Link"
    47→        }
    48→    }
    49→
    50→    var description: String {
    51→        switch self {
    52→        case .semantic: return "Notes with similar topics or concepts"
    53→        case .temporal: return "Notes related by date or timeline"
    54→        case .hierarchical: return "Parent-child relationship between notes"
    55→        case .reference: return "One note references concepts from another"
    56→        case .action: return "Tasks or actions that relate to other notes"
    57→        case .wikiLink: return "Explicit [[wiki-link]] in the note"
    58→        }
    59→    }
    60→}
    61→
    62→// MARK: - Note Connection
    63→
    64→/// A connection between two notes
    65→struct NoteConnection: Identifiable, Codable, Hashable {
    66→    let id: UUID
    67→    let sourceNotePath: String
    68→    let targetNotePath: String
    69→    let connectionType: ConnectionType
    70→    var strength: Double           // 0.0 - 1.0
    71→    var reason: String             // AI-generated explanation
    72→    var isUserAccepted: Bool       // User confirmed this connection
    73→    var isUserRejected: Bool       // User rejected this connection
    74→    var createdAt: Date
    75→    var updatedAt: Date
    76→    var keywords: [String]         // Shared keywords/concepts
    77→
    78→    var isExistingWikiLink: Bool { connectionType == .wikiLink }
    79→
    80→    var sourceFileName: String {
    81→        URL(fileURLWithPath: sourceNotePath).deletingPathExtension().lastPathComponent
    82→    }
    83→
    84→    var targetFileName: String {
    85→        URL(fileURLWithPath: targetNotePath).deletingPathExtension().lastPathComponent
    86→    }
    87→
    88→    var strengthLabel: String {
    89→        switch strength {
    90→        case 0.8...1.0: return "Strong"
    91→        case 0.6..<0.8: return "Good"
    92→        case 0.4..<0.6: return "Moderate"
    93→        default: return "Weak"
    94→        }
    95→    }
    96→
    97→    var strengthColor: Color {
    98→        switch strength {
    99→        case 0.8...1.0: return .green
   100→        case 0.6..<0.8: return .blue
   101→        case 0.4..<0.6: return .orange
   102→        default: return .gray
   103→        }
   104→    }
   105→
   106→    init(
   107→        id: UUID = UUID(),
   108→        sourceNotePath: String,
   109→        targetNotePath: String,
   110→        connectionType: ConnectionType,
   111→        strength: Double,
   112→        reason: String,
   113→        isUserAccepted: Bool = false,
   114→        isUserRejected: Bool = false,
   115→        createdAt: Date = Date(),
   116→        updatedAt: Date = Date(),
   117→        keywords: [String] = []
   118→    ) {
   119→        self.id = id
   120→        self.sourceNotePath = sourceNotePath
   121→        self.targetNotePath = targetNotePath
   122→        self.connectionType = connectionType
   123→        self.strength = strength
   124→        self.reason = reason
   125→        self.isUserAccepted = isUserAccepted
   126→        self.isUserRejected = isUserRejected
   127→        self.createdAt = createdAt
   128→        self.updatedAt = updatedAt
   129→        self.keywords = keywords
   130→    }
   131→}
   132→
   133→// MARK: - Connection Suggestion
   134→
   135→/// Status of a connection suggestion
   136→enum SuggestionStatus: String, Codable {
   137→    case pending
   138→    case accepted
   139→    case rejected
   140→    case deferred   // "Ask later"
   141→}
   142→
   143→/// A suggested connection waiting for user action
   144→struct ConnectionSuggestion: Identifiable, Codable {
   145→    let id: UUID
   146→    var suggestedConnection: NoteConnection
   147→    var status: SuggestionStatus
   148→    var createdAt: Date
   149→    var processedAt: Date?
   150→    var batchId: UUID?             // Which analysis batch created this
   151→
   152→    var isPending: Bool { status == .pending }
   153→
   154→    init(
   155→        id: UUID = UUID(),
   156→        suggestedConnection: NoteConnection,
   157→        status: SuggestionStatus = .pending,
   158→        createdAt: Date = Date(),
   159→        processedAt: Date? = nil,
   160→        batchId: UUID? = nil
   161→    ) {
   162→        self.id = id
   163→        self.suggestedConnection = suggestedConnection
   164→        self.status = status
   165→        self.createdAt = createdAt
   166→        self.processedAt = processedAt
   167→        self.batchId = batchId
   168→    }
   169→}
   170→
   171→// MARK: - Note Node
   172→
   173→/// Note metadata for graph representation
   174→struct NoteNode: Identifiable, Codable, Hashable {
   175→    let id: String                 // path
   176→    let path: String
   177→    let title: String
   178→    var incomingConnections: Int
   179→    var outgoingConnections: Int
   180→    var clusterIds: [UUID]
   181→    var isOrphan: Bool
   182→    var lastAnalyzed: Date?
   183→    var contentHash: String?       // Detect changes
   184→    var tags: [String]
   185→    var metadata: NoteMetadata?
   186→
   187→    var totalConnections: Int {
   188→        incomingConnections + outgoingConnections
   189→    }
   190→
   191→    var displayName: String {
   192→        title.replacingOccurrences(of: ".md", with: "")
   193→    }
   194→
   195→    init(
   196→        path: String,
   197→        title: String? = nil,
   198→        incomingConnections: Int = 0,
   199→        outgoingConnections: Int = 0,
   200→        clusterIds: [UUID] = [],
   201→        isOrphan: Bool = true,
   202→        lastAnalyzed: Date? = nil,
   203→        contentHash: String? = nil,
   204→        tags: [String] = [],
   205→        metadata: NoteMetadata? = nil
   206→    ) {
   207→        self.id = path
   208→        self.path = path
   209→        self.title = title ?? URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent
   210→        self.incomingConnections = incomingConnections
   211→        self.outgoingConnections = outgoingConnections
   212→        self.clusterIds = clusterIds
   213→        self.isOrphan = isOrphan
   214→        self.lastAnalyzed = lastAnalyzed
   215→        self.contentHash = contentHash
   216→        self.tags = tags
   217→        self.metadata = metadata
   218→    }
   219→}
   220→
   221→// MARK: - Note Metadata
   222→
   223→/// Parsed metadata from note content
   224→struct NoteMetadata: Codable, Hashable {
   225→    var connections: [String]      // Parsed from ## Conexoes section
   226→    var metadataSection: String?   // Raw ## Metadados content
   227→    var tipo: String?              // Note type from metadata
   228→    var categoria: String?         // Category
   229→    var status: String?            // Status emoji/text
   230→    var prioridade: String?        // Priority
   231→
   232→    init(
   233→        connections: [String] = [],
   234→        metadataSection: String? = nil,
   235→        tipo: String? = nil,
   236→        categoria: String? = nil,
   237→        status: String? = nil,
   238→        prioridade: String? = nil
   239→    ) {
   240→        self.connections = connections
   241→        self.metadataSection = metadataSection
   242→        self.tipo = tipo
   243→        self.categoria = categoria
   244→        self.status = status
   245→        self.prioridade = prioridade
   246→    }
   247→}
   248→
   249→// MARK: - Note Cluster
   250→
   251→/// Theme clusters for grouping related notes
   252→struct NoteCluster: Identifiable, Codable {
   253→    let id: UUID
   254→    var name: String               // AI-generated or user-defined
   255→    var theme: String              // Description of the theme
   256→    var noteIds: [String]          // paths of notes in cluster
   257→    var keywords: [String]
   258→    var colorHex: String           // Hex color for visualization
   259→    var createdAt: Date
   260→
   261→    var color: Color {
   262→        Color(hex: colorHex) ?? .purple
   263→    }
   264→
   265→    var noteCount: Int {
   266→        noteIds.count
   267→    }
   268→
   269→    init(
   270→        id: UUID = UUID(),
   271→        name: String,
   272→        theme: String,
   273→        noteIds: [String] = [],
   274→        keywords: [String] = [],
   275→        colorHex: String = "#8B5CF6",
   276→        createdAt: Date = Date()
   277→    ) {
   278→        self.id = id
   279→        self.name = name
   280→        self.theme = theme
   281→        self.noteIds = noteIds
   282→        self.keywords = keywords
   283→        self.colorHex = colorHex
   284→        self.createdAt = createdAt
   285→    }
   286→}
   287→
   288→// MARK: - Color Extension
   289→
   290→extension Color {
   291→    init?(hex: String) {
   292→        var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
   293→        hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
   294→
   295→        var rgb: UInt64 = 0
   296→        guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil }
   297→
   298→        let r = Double((rgb & 0xFF0000) >> 16) / 255.0
   299→        let g = Double((rgb & 0x00FF00) >> 8) / 255.0
   300→        let b = Double(rgb & 0x0000FF) / 255.0
   301→
   302→        self.init(red: r, green: g, blue: b)
   303→    }
   304→
   305→    func toHex() -> String {
   306→        guard let components = UIColor(self).cgColor.components else { return "#8B5CF6" }
   307→        let r = Int(components[0] * 255)
   308→        let g = Int(components[1] * 255)
   309→        let b = Int(components[2] * 255)
   310→        return String(format: "#%02X%02X%02X", r, g, b)
   311→    }
   312→}
   313→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

import Foundation
import SwiftUI

// MARK: - Connection Types

/// Types of connections between notes
enum ConnectionType: String, Codable, CaseIterable, Identifiable {
    case semantic      // Similar topics/concepts (AI-detected)
    case temporal      // Related by date/timeline
    case hierarchical  // Parent-child relationships
    case reference     // One note mentions concepts from another
    case action        // Tasks that relate to other notes
    case wikiLink      // Explicit [[wiki-link]]

    var id: String { rawValue }

    var icon: String {
        switch self {
        case .semantic: return "brain.head.profile"
        case .temporal: return "calendar"
        case .hierarchical: return "arrow.up.arrow.down"
        case .reference: return "quote.bubble"
        case .action: return "checklist"
        case .wikiLink: return "link"
        }
    }

    var color: Color {
        switch self {
        case .semantic: return .purple
        case .temporal: return .blue
        case .hierarchical: return .orange
        case .reference: return .green
        case .action: return .red
        case .wikiLink: return .gray
        }
    }

    var displayName: String {
        switch self {
        case .semantic: return "Semantic"
        case .temporal: return "Temporal"
        case .hierarchical: return "Hierarchical"
        case .reference: return "Reference"
        case .action: return "Action"
        case .wikiLink: return "Wiki Link"
        }
    }

    var description: String {
        switch self {
        case .semantic: return "Notes with similar topics or concepts"
        case .temporal: return "Notes related by date or timeline"
        case .hierarchical: return "Parent-child relationship between notes"
        case .reference: return "One note references concepts from another"
        case .action: return "Tasks or actions that relate to other notes"
        case .wikiLink: return "Explicit [[wiki-link]] in the note"
        }
    }
}

// MARK: - Note Connection

/// A connection between two notes
struct NoteConnection: Identifiable, Codable, Hashable {
    let id: UUID
    let sourceNotePath: String
    let targetNotePath: String
    let connectionType: ConnectionType
    var strength: Double           // 0.0 - 1.0
    var reason: String             // AI-generated explanation
    var isUserAccepted: Bool       // User confirmed this connection
    var isUserRejected: Bool       // User rejected this connection
    var createdAt: Date
    var updatedAt: Date
    var keywords: [String]         // Shared keywords/concepts

    var isExistingWikiLink: Bool { connectionType == .wikiLink }

    var sourceFileName: String {
        URL(fileURLWithPath: sourceNotePath).deletingPathExtension().lastPathComponent
    }

    var targetFileName: String {
        URL(fileURLWithPath: targetNotePath).deletingPathExtension().lastPathComponent
    }

    var strengthLabel: String {
        switch strength {
        case 0.8...1.0: return "Strong"
        case 0.6..<0.8: return "Good"
        case 0.4..<0.6: return "Moderate"
        default: return "Weak"
        }
    }

    var strengthColor: Color {
        switch strength {
        case 0.8...1.0: return .green
        case 0.6..<0.8: return .blue
        case 0.4..<0.6: return .orange
        default: return .gray
        }
    }

    init(
        id: UUID = UUID(),
        sourceNotePath: String,
        targetNotePath: String,
        connectionType: ConnectionType,
        strength: Double,
        reason: String,
        isUserAccepted: Bool = false,
        isUserRejected: Bool = false,
        createdAt: Date = Date(),
        updatedAt: Date = Date(),
        keywords: [String] = []
    ) {
        self.id = id
        self.sourceNotePath = sourceNotePath
        self.targetNotePath = targetNotePath
        self.connectionType = connectionType
        self.strength = strength
        self.reason = reason
        self.isUserAccepted = isUserAccepted
        self.isUserRejected = isUserRejected
        self.createdAt = createdAt
        self.updatedAt = updatedAt
        self.keywords = keywords
    }
}

// MARK: - Connection Suggestion

/// Status of a connection suggestion
enum SuggestionStatus: String, Codable {
    case pending
    case accepted
    case rejected
    case deferred   // "Ask later"
}

/// A suggested connection waiting for user action
struct ConnectionSuggestion: Identifiable, Codable {
    let id: UUID
    var suggestedConnection: NoteConnection
    var status: SuggestionStatus
    var createdAt: Date
    var processedAt: Date?
    var batchId: UUID?             // Which analysis batch created this

    var isPending: Bool { status == .pending }

    init(
        id: UUID = UUID(),
        suggestedConnection: NoteConnection,
        status: SuggestionStatus = .pending,
        createdAt: Date = Date(),
        processedAt: Date? = nil,
        batchId: UUID? = nil
    ) {
        self.id = id
        self.suggestedConnection = suggestedConnection
        self.status = status
        self.createdAt = createdAt
        self.processedAt = processedAt
        self.batchId = batchId
    }
}

// MARK: - Note Node

/// Note metadata for graph representation
struct NoteNode: Identifiable, Codable, Hashable {
    let id: String                 // path
    let path: String
    let title: String
    var incomingConnections: Int
    var outgoingConnections: Int
    var clusterIds: [UUID]
    var isOrphan: Bool
    var lastAnalyzed: Date?
    var contentHash: String?       // Detect changes
    var tags: [String]
    var metadata: NoteMetadata?

    var totalConnections: Int {
        incomingConnections + outgoingConnections
    }

    var displayName: String {
        title.replacingOccurrences(of: ".md", with: "")
    }

    init(
        path: String,
        title: String? = nil,
        incomingConnections: Int = 0,
        outgoingConnections: Int = 0,
        clusterIds: [UUID] = [],
        isOrphan: Bool = true,
        lastAnalyzed: Date? = nil,
        contentHash: String? = nil,
        tags: [String] = [],
        metadata: NoteMetadata? = nil
    ) {
        self.id = path
        self.path = path
        self.title = title ?? URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent
        self.incomingConnections = incomingConnections
        self.outgoingConnections = outgoingConnections
        self.clusterIds = clusterIds
        self.isOrphan = isOrphan
        self.lastAnalyzed = lastAnalyzed
        self.contentHash = contentHash
        self.tags = tags
        self.metadata = metadata
    }
}

// MARK: - Note Metadata

/// Parsed metadata from note content
struct NoteMetadata: Codable, Hashable {
    var connections: [String]      // Parsed from ## Conexoes section
    var metadataSection: String?   // Raw ## Metadados content
    var tipo: String?              // Note type from metadata
    var categoria: String?         // Category
    var status: String?            // Status emoji/text
    var prioridade: String?        // Priority

    init(
        connections: [String] = [],
        metadataSection: String? = nil,
        tipo: String? = nil,
        categoria: String? = nil,
        status: String? = nil,
        prioridade: String? = nil
    ) {
        self.connections = connections
        self.metadataSection = metadataSection
        self.tipo = tipo
        self.categoria = categoria
        self.status = status
        self.prioridade = prioridade
    }
}

// MARK: - Note Cluster

/// Theme clusters for grouping related notes
struct NoteCluster: Identifiable, Codable {
    let id: UUID
    var name: String               // AI-generated or user-defined
    var theme: String              // Description of the theme
    var noteIds: [String]          // paths of notes in cluster
    var keywords: [String]
    var colorHex: String           // Hex color for visualization
    var createdAt: Date

    var color: Color {
        Color(hex: colorHex) ?? .purple
    }

    var noteCount: Int {
        noteIds.count
    }

    init(
        id: UUID = UUID(),
        name: String,
        theme: String,
        noteIds: [String] = [],
        keywords: [String] = [],
        colorHex: String = "#8B5CF6",
        createdAt: Date = Date()
    ) {
        self.id = id
        self.name = name
        self.theme = theme
        self.noteIds = noteIds
        self.keywords = keywords
        self.colorHex = colorHex
        self.createdAt = createdAt
    }
}

// MARK: - Color Extension

extension Color {
    init?(hex: String) {
        var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
        hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")

        var rgb: UInt64 = 0
        guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil }

        let r = Double((rgb & 0xFF0000) >> 16) / 255.0
        let g = Double((rgb & 0x00FF00) >> 8) / 255.0
        let b = Double(rgb & 0x0000FF) / 255.0

        self.init(red: r, green: g, blue: b)
    }

    func toHex() -> String {
        guard let components = UIColor(self).cgColor.components else { return "#8B5CF6" }
        let r = Int(components[0] * 255)
        let g = Int(components[1] * 255)
        let b = Int(components[2] * 255)
        return String(format: "#%02X%02X%02X", r, g, b)
    }
}


Now let me search for the AnalysisProgress model:


Found 1 file
ObsidianViewer-iOS/Models/GraphModels.swift


Perfect! Let me read the GraphModels file:


     1→import Foundation
     2→import SwiftUI
     3→
     4→// MARK: - Vault Graph
     5→
     6→/// Full vault graph representation
     7→struct VaultGraph: Codable {
     8→    var nodes: [String: NoteNode]           // path -> node
     9→    var edges: [NoteConnection]
    10→    var clusters: [NoteCluster]
    11→    var lastFullAnalysis: Date?
    12→    var totalNotes: Int
    13→    var orphanCount: Int
    14→    var connectionCount: Int
    15→
    16→    // MARK: - Computed Properties
    17→
    18→    var averageConnectionsPerNote: Double {
    19→        guard totalNotes > 0 else { return 0 }
    20→        let totalConnections = nodes.values.reduce(0) { $0 + $1.totalConnections }
    21→        return Double(totalConnections) / Double(totalNotes)
    22→    }
    23→
    24→    var mostConnectedNotes: [NoteNode] {
    25→        Array(nodes.values.sorted { $0.totalConnections > $1.totalConnections }.prefix(10))
    26→    }
    27→
    28→    var leastConnectedNotes: [NoteNode] {
    29→        Array(nodes.values.filter { !$0.isOrphan }.sorted { $0.totalConnections < $1.totalConnections }.prefix(10))
    30→    }
    31→
    32→    var orphanNodes: [NoteNode] {
    33→        nodes.values.filter { $0.isOrphan }.sorted { $0.title < $1.title }
    34→    }
    35→
    36→    var connectionsByType: [ConnectionType: Int] {
    37→        Dictionary(grouping: edges, by: { $0.connectionType })
    38→            .mapValues { $0.count }
    39→    }
    40→
    41→    // MARK: - Init
    42→
    43→    init(
    44→        nodes: [String: NoteNode] = [:],
    45→        edges: [NoteConnection] = [],
    46→        clusters: [NoteCluster] = [],
    47→        lastFullAnalysis: Date? = nil,
    48→        totalNotes: Int = 0,
    49→        orphanCount: Int = 0,
    50→        connectionCount: Int = 0
    51→    ) {
    52→        self.nodes = nodes
    53→        self.edges = edges
    54→        self.clusters = clusters
    55→        self.lastFullAnalysis = lastFullAnalysis
    56→        self.totalNotes = totalNotes
    57→        self.orphanCount = orphanCount
    58→        self.connectionCount = connectionCount
    59→    }
    60→
    61→    // MARK: - Methods
    62→
    63→    /// Get all connections for a specific note
    64→    func connections(for notePath: String) -> [NoteConnection] {
    65→        edges.filter { $0.sourceNotePath == notePath || $0.targetNotePath == notePath }
    66→    }
    67→
    68→    /// Get outgoing connections from a note
    69→    func outgoingConnections(from notePath: String) -> [NoteConnection] {
    70→        edges.filter { $0.sourceNotePath == notePath }
    71→    }
    72→
    73→    /// Get incoming connections to a note
    74→    func incomingConnections(to notePath: String) -> [NoteConnection] {
    75→        edges.filter { $0.targetNotePath == notePath }
    76→    }
    77→
    78→    /// Get cluster for a note
    79→    func cluster(for notePath: String) -> NoteCluster? {
    80→        clusters.first { $0.noteIds.contains(notePath) }
    81→    }
    82→
    83→    /// Check if two notes are connected
    84→    func areConnected(_ path1: String, _ path2: String) -> Bool {
    85→        edges.contains {
    86→            ($0.sourceNotePath == path1 && $0.targetNotePath == path2) ||
    87→            ($0.sourceNotePath == path2 && $0.targetNotePath == path1)
    88→        }
    89→    }
    90→}
    91→
    92→// MARK: - Analysis Progress
    93→
    94→/// Phases of the analysis pipeline
    95→enum AnalysisPhase: String, Codable {
    96→    case idle
    97→    case loadingFiles
    98→    case parsingWikiLinks
    99→    case analyzingContent
   100→    case buildingGraph
   101→    case identifyingClusters
   102→    case generatingSuggestions
   103→    case complete
   104→    case failed
   105→
   106→    var displayName: String {
   107→        switch self {
   108→        case .idle: return "Ready"
   109→        case .loadingFiles: return "Loading files..."
   110→        case .parsingWikiLinks: return "Parsing wiki-links..."
   111→        case .analyzingContent: return "Analyzing content..."
   112→        case .buildingGraph: return "Building graph..."
   113→        case .identifyingClusters: return "Identifying clusters..."
   114→        case .generatingSuggestions: return "Generating suggestions..."
   115→        case .complete: return "Complete"
   116→        case .failed: return "Failed"
   117→        }
   118→    }
   119→
   120→    var icon: String {
   121→        switch self {
   122→        case .idle: return "circle"
   123→        case .loadingFiles: return "doc.on.doc"
   124→        case .parsingWikiLinks: return "link"
   125→        case .analyzingContent: return "brain"
   126→        case .buildingGraph: return "point.3.connected.trianglepath.dotted"
   127→        case .identifyingClusters: return "circle.hexagongrid"
   128→        case .generatingSuggestions: return "lightbulb"
   129→        case .complete: return "checkmark.circle.fill"
   130→        case .failed: return "xmark.circle.fill"
   131→        }
   132→    }
   133→
   134→    var color: Color {
   135→        switch self {
   136→        case .idle: return .gray
   137→        case .loadingFiles, .parsingWikiLinks, .analyzingContent, .buildingGraph, .identifyingClusters, .generatingSuggestions:
   138→            return .blue
   139→        case .complete: return .green
   140→        case .failed: return .red
   141→        }
   142→    }
   143→}
   144→
   145→/// Analysis progress tracking with real-time metrics
   146→struct AnalysisProgress: Codable {
   147→    // MARK: - Core Progress
   148→    var totalNotes: Int
   149→    var processedNotes: Int
   150→    var currentPhase: AnalysisPhase
   151→    var startedAt: Date
   152→    var estimatedCompletion: Date?
   153→    var errors: [AnalysisError]
   154→    var currentBatch: Int
   155→    var totalBatches: Int
   156→
   157→    // MARK: - Phase Timing
   158→    var phaseStartTimes: [String: Date]  // AnalysisPhase.rawValue -> Date
   159→    var phaseEndTimes: [String: Date]    // AnalysisPhase.rawValue -> Date
   160→    var completedPhases: [AnalysisPhase]
   161→
   162→    // MARK: - Throughput Metrics
   163→    var notesPerSecond: Double
   164→    var batchesPerMinute: Double
   165→    var lastThroughputUpdate: Date?
   166→
   167→    // MARK: - Current Batch Details
   168→    var currentBatchNotes: [String]      // Note paths being processed
   169→    var connectionsFoundThisBatch: Int
   170→    var totalConnectionsFound: Int
   171→
   172→    // MARK: - Status Messages
   173→    var statusMessage: String?           // Current status text
   174→    var currentNotePath: String?         // Note currently being processed
   175→
   176→    // MARK: - Computed Properties
   177→
   178→    var percentComplete: Double {
   179→        guard totalNotes > 0 else { return 0 }
   180→        return Double(processedNotes) / Double(totalNotes)
   181→    }
   182→
   183→    var elapsedTime: TimeInterval {
   184→        Date().timeIntervalSince(startedAt)
   185→    }
   186→
   187→    var isComplete: Bool {
   188→        currentPhase == .complete
   189→    }
   190→
   191→    var isFailed: Bool {
   192→        currentPhase == .failed
   193→    }
   194→
   195→    var hasErrors: Bool {
   196→        !errors.isEmpty
   197→    }
   198→
   199→    var isActive: Bool {
   200→        currentPhase != .idle && currentPhase != .complete && currentPhase != .failed
   201→    }
   202→
   203→    /// Duration of current phase
   204→    var currentPhaseDuration: TimeInterval {
   205→        guard let start = phaseStartTimes[currentPhase.rawValue] else { return 0 }
   206→        return Date().timeIntervalSince(start)
   207→    }
   208→
   209→    /// Duration of a completed phase
   210→    func phaseDuration(for phase: AnalysisPhase) -> TimeInterval? {
   211→        guard let start = phaseStartTimes[phase.rawValue],
   212→              let end = phaseEndTimes[phase.rawValue] else { return nil }
   213→        return end.timeIntervalSince(start)
   214→    }
   215→
   216→    /// Estimated seconds remaining based on throughput
   217→    var estimatedSecondsRemaining: Int? {
   218→        guard notesPerSecond > 0, processedNotes < totalNotes else { return nil }
   219→        let remainingNotes = totalNotes - processedNotes
   220→        return Int(Double(remainingNotes) / notesPerSecond)
   221→    }
   222→
   223→    /// Formatted ETA string
   224→    var etaString: String? {
   225→        guard let seconds = estimatedSecondsRemaining else { return nil }
   226→        if seconds < 60 {
   227→            return "\(seconds)s"
   228→        } else if seconds < 3600 {
   229→            return "\(seconds / 60)m \(seconds % 60)s"
   230→        } else {
   231→            return "\(seconds / 3600)h \(seconds % 3600 / 60)m"
   232→        }
   233→    }
   234→
   235→    /// Formatted elapsed time string
   236→    var elapsedTimeString: String {
   237→        let seconds = Int(elapsedTime)
   238→        if seconds < 60 {
   239→            return "\(seconds)s"
   240→        } else if seconds < 3600 {
   241→            return "\(seconds / 60)m \(seconds % 60)s"
   242→        } else {
   243→            return "\(seconds / 3600)h \(seconds % 3600 / 60)m"
   244→        }
   245→    }
   246→
   247→    /// All phases in order
   248→    static let allPhases: [AnalysisPhase] = [
   249→        .loadingFiles,
   250→        .parsingWikiLinks,
   251→        .analyzingContent,
   252→        .buildingGraph,
   253→        .identifyingClusters,
   254→        .generatingSuggestions
   255→    ]
   256→
   257→    /// Index of current phase (0-based)
   258→    var currentPhaseIndex: Int {
   259→        Self.allPhases.firstIndex(of: currentPhase) ?? 0
   260→    }
   261→
   262→    /// Total number of phases
   263→    var totalPhases: Int {
   264→        Self.allPhases.count
   265→    }
   266→
   267→    // MARK: - Init
   268→
   269→    init(
   270→        totalNotes: Int = 0,
   271→        processedNotes: Int = 0,
   272→        currentPhase: AnalysisPhase = .idle,
   273→        startedAt: Date = Date(),
   274→        estimatedCompletion: Date? = nil,
   275→        errors: [AnalysisError] = [],
   276→        currentBatch: Int = 0,
   277→        totalBatches: Int = 0,
   278→        phaseStartTimes: [String: Date] = [:],
   279→        phaseEndTimes: [String: Date] = [:],
   280→        completedPhases: [AnalysisPhase] = [],
   281→        notesPerSecond: Double = 0,
   282→        batchesPerMinute: Double = 0,
   283→        lastThroughputUpdate: Date? = nil,
   284→        currentBatchNotes: [String] = [],
   285→        connectionsFoundThisBatch: Int = 0,
   286→        totalConnectionsFound: Int = 0,
   287→        statusMessage: String? = nil,
   288→        currentNotePath: String? = nil
   289→    ) {
   290→        self.totalNotes = totalNotes
   291→        self.processedNotes = processedNotes
   292→        self.currentPhase = currentPhase
   293→        self.startedAt = startedAt
   294→        self.estimatedCompletion = estimatedCompletion
   295→        self.errors = errors
   296→        self.currentBatch = currentBatch
   297→        self.totalBatches = totalBatches
   298→        self.phaseStartTimes = phaseStartTimes
   299→        self.phaseEndTimes = phaseEndTimes
   300→        self.completedPhases = completedPhases
   301→        self.notesPerSecond = notesPerSecond
   302→        self.batchesPerMinute = batchesPerMinute
   303→        self.lastThroughputUpdate = lastThroughputUpdate
   304→        self.currentBatchNotes = currentBatchNotes
   305→        self.connectionsFoundThisBatch = connectionsFoundThisBatch
   306→        self.totalConnectionsFound = totalConnectionsFound
   307→        self.statusMessage = statusMessage
   308→        self.currentNotePath = currentNotePath
   309→    }
   310→
   311→    // MARK: - Mutation Methods
   312→
   313→    /// Start a new phase
   314→    mutating func startPhase(_ phase: AnalysisPhase) {
   315→        // End current phase if active
   316→        if currentPhase != .idle && phaseStartTimes[currentPhase.rawValue] != nil {
   317→            phaseEndTimes[currentPhase.rawValue] = Date()
   318→            if !completedPhases.contains(currentPhase) {
   319→                completedPhases.append(currentPhase)
   320→            }
   321→        }
   322→
   323→        currentPhase = phase
   324→        phaseStartTimes[phase.rawValue] = Date()
   325→        statusMessage = phase.displayName
   326→    }
   327→
   328→    /// Update throughput metrics
   329→    mutating func updateThroughput(notesProcessed: Int, timeInterval: TimeInterval) {
   330→        guard timeInterval > 0 else { return }
   331→        notesPerSecond = Double(notesProcessed) / timeInterval
   332→        lastThroughputUpdate = Date()
   333→    }
   334→
   335→    /// Update batch throughput
   336→    mutating func updateBatchThroughput(batchesCompleted: Int, timeInterval: TimeInterval) {
   337→        guard timeInterval > 0 else { return }
   338→        batchesPerMinute = Double(batchesCompleted) / (timeInterval / 60.0)
   339→    }
   340→
   341→    /// Set current batch info
   342→    mutating func setCurrentBatch(notes: [String], batchNumber: Int) {
   343→        currentBatchNotes = notes
   344→        currentBatch = batchNumber
   345→        connectionsFoundThisBatch = 0
   346→    }
   347→
   348→    /// Record a connection found
   349→    mutating func recordConnection() {
   350→        connectionsFoundThisBatch += 1
   351→        totalConnectionsFound += 1
   352→    }
   353→
   354→    /// Record multiple connections
   355→    mutating func recordConnections(_ count: Int) {
   356→        connectionsFoundThisBatch += count
   357→        totalConnectionsFound += count
   358→    }
   359→
   360→    /// Mark analysis as complete
   361→    mutating func markComplete() {
   362→        if currentPhase != .idle {
   363→            phaseEndTimes[currentPhase.rawValue] = Date()
   364→            if !completedPhases.contains(currentPhase) {
   365→                completedPhases.append(currentPhase)
   366→            }
   367→        }
   368→        currentPhase = .complete
   369→        statusMessage = "Analysis complete"
   370→        currentBatchNotes = []
   371→    }
   372→
   373→    /// Mark analysis as failed
   374→    mutating func markFailed(error: String) {
   375→        currentPhase = .failed
   376→        statusMessage = error
   377→    }
   378→}
   379→
   380→/// Analysis error tracking
   381→struct AnalysisError: Codable, Identifiable {
   382→    let id: UUID
   383→    let notePath: String
   384→    let error: String
   385→    let timestamp: Date
   386→    let phase: AnalysisPhase
   387→
   388→    init(
   389→        id: UUID = UUID(),
   390→        notePath: String,
   391→        error: String,
   392→        timestamp: Date = Date(),
   393→        phase: AnalysisPhase
   394→    ) {
   395→        self.id = id
   396→        self.notePath = notePath
   397→        self.error = error
   398→        self.timestamp = timestamp
   399→        self.phase = phase
   400→    }

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

import Foundation
import SwiftUI

// MARK: - Vault Graph

/// Full vault graph representation
struct VaultGraph: Codable {
    var nodes: [String: NoteNode]           // path -> node
    var edges: [NoteConnection]
    var clusters: [NoteCluster]
    var lastFullAnalysis: Date?
    var totalNotes: Int
    var orphanCount: Int
    var connectionCount: Int

    // MARK: - Computed Properties

    var averageConnectionsPerNote: Double {
        guard totalNotes > 0 else { return 0 }
        let totalConnections = nodes.values.reduce(0) { $0 + $1.totalConnections }
        return Double(totalConnections) / Double(totalNotes)
    }

    var mostConnectedNotes: [NoteNode] {
        Array(nodes.values.sorted { $0.totalConnections > $1.totalConnections }.prefix(10))
    }

    var leastConnectedNotes: [NoteNode] {
        Array(nodes.values.filter { !$0.isOrphan }.sorted { $0.totalConnections < $1.totalConnections }.prefix(10))
    }

    var orphanNodes: [NoteNode] {
        nodes.values.filter { $0.isOrphan }.sorted { $0.title < $1.title }
    }

    var connectionsByType: [ConnectionType: Int] {
        Dictionary(grouping: edges, by: { $0.connectionType })
            .mapValues { $0.count }
    }

    // MARK: - Init

    init(
        nodes: [String: NoteNode] = [:],
        edges: [NoteConnection] = [],
        clusters: [NoteCluster] = [],
        lastFullAnalysis: Date? = nil,
        totalNotes: Int = 0,
        orphanCount: Int = 0,
        connectionCount: Int = 0
    ) {
        self.nodes = nodes
        self.edges = edges
        self.clusters = clusters
        self.lastFullAnalysis = lastFullAnalysis
        self.totalNotes = totalNotes
        self.orphanCount = orphanCount
        self.connectionCount = connectionCount
    }

    // MARK: - Methods

    /// Get all connections for a specific note
    func connections(for notePath: String) -> [NoteConnection] {
        edges.filter { $0.sourceNotePath == notePath || $0.targetNotePath == notePath }
    }

    /// Get outgoing connections from a note
    func outgoingConnections(from notePath: String) -> [NoteConnection] {
        edges.filter { $0.sourceNotePath == notePath }
    }

    /// Get incoming connections to a note
    func incomingConnections(to notePath: String) -> [NoteConnection] {
        edges.filter { $0.targetNotePath == notePath }
    }

    /// Get cluster for a note
    func cluster(for notePath: String) -> NoteCluster? {
        clusters.first { $0.noteIds.contains(notePath) }
    }

    /// Check if two notes are connected
    func areConnected(_ path1: String, _ path2: String) -> Bool {
        edges.contains {
            ($0.sourceNotePath == path1 && $0.targetNotePath == path2) ||
            ($0.sourceNotePath == path2 && $0.targetNotePath == path1)
        }
    }
}

// MARK: - Analysis Progress

/// Phases of the analysis pipeline
enum AnalysisPhase: String, Codable {
    case idle
    case loadingFiles
    case parsingWikiLinks
    case analyzingContent
    case buildingGraph
    case identifyingClusters
    case generatingSuggestions
    case complete
    case failed

    var displayName: String {
        switch self {
        case .idle: return "Ready"
        case .loadingFiles: return "Loading files..."
        case .parsingWikiLinks: return "Parsing wiki-links..."
        case .analyzingContent: return "Analyzing content..."
        case .buildingGraph: return "Building graph..."
        case .identifyingClusters: return "Identifying clusters..."
        case .generatingSuggestions: return "Generating suggestions..."
        case .complete: return "Complete"
        case .failed: return "Failed"
        }
    }

    var icon: String {
        switch self {
        case .idle: return "circle"
        case .loadingFiles: return "doc.on.doc"
        case .parsingWikiLinks: return "link"
        case .analyzingContent: return "brain"
        case .buildingGraph: return "point.3.connected.trianglepath.dotted"
        case .identifyingClusters: return "circle.hexagongrid"
        case .generatingSuggestions: return "lightbulb"
        case .complete: return "checkmark.circle.fill"
        case .failed: return "xmark.circle.fill"
        }
    }

    var color: Color {
        switch self {
        case .idle: return .gray
        case .loadingFiles, .parsingWikiLinks, .analyzingContent, .buildingGraph, .identifyingClusters, .generatingSuggestions:
            return .blue
        case .complete: return .green
        case .failed: return .red
        }
    }
}

/// Analysis progress tracking with real-time metrics
struct AnalysisProgress: Codable {
    // MARK: - Core Progress
    var totalNotes: Int
    var processedNotes: Int
    var currentPhase: AnalysisPhase
    var startedAt: Date
    var estimatedCompletion: Date?
    var errors: [AnalysisError]
    var currentBatch: Int
    var totalBatches: Int

    // MARK: - Phase Timing
    var phaseStartTimes: [String: Date]  // AnalysisPhase.rawValue -> Date
    var phaseEndTimes: [String: Date]    // AnalysisPhase.rawValue -> Date
    var completedPhases: [AnalysisPhase]

    // MARK: - Throughput Metrics
    var notesPerSecond: Double
    var batchesPerMinute: Double
    var lastThroughputUpdate: Date?

    // MARK: - Current Batch Details
    var currentBatchNotes: [String]      // Note paths being processed
    var connectionsFoundThisBatch: Int
    var totalConnectionsFound: Int

    // MARK: - Status Messages
    var statusMessage: String?           // Current status text
    var currentNotePath: String?         // Note currently being processed

    // MARK: - Computed Properties

    var percentComplete: Double {
        guard totalNotes > 0 else { return 0 }
        return Double(processedNotes) / Double(totalNotes)
    }

    var elapsedTime: TimeInterval {
        Date().timeIntervalSince(startedAt)
    }

    var isComplete: Bool {
        currentPhase == .complete
    }

    var isFailed: Bool {
        currentPhase == .failed
    }

    var hasErrors: Bool {
        !errors.isEmpty
    }

    var isActive: Bool {
        currentPhase != .idle && currentPhase != .complete && currentPhase != .failed
    }

    /// Duration of current phase
    var currentPhaseDuration: TimeInterval {
        guard let start = phaseStartTimes[currentPhase.rawValue] else { return 0 }
        return Date().timeIntervalSince(start)
    }

    /// Duration of a completed phase
    func phaseDuration(for phase: AnalysisPhase) -> TimeInterval? {
        guard let start = phaseStartTimes[phase.rawValue],
              let end = phaseEndTimes[phase.rawValue] else { return nil }
        return end.timeIntervalSince(start)
    }

    /// Estimated seconds remaining based on throughput
    var estimatedSecondsRemaining: Int? {
        guard notesPerSecond > 0, processedNotes < totalNotes else { return nil }
        let remainingNotes = totalNotes - processedNotes
        return Int(Double(remainingNotes) / notesPerSecond)
    }

    /// Formatted ETA string
    var etaString: String? {
        guard let seconds = estimatedSecondsRemaining else { return nil }
        if seconds < 60 {
            return "\(seconds)s"
        } else if seconds < 3600 {
            return "\(seconds / 60)m \(seconds % 60)s"
        } else {
            return "\(seconds / 3600)h \(seconds % 3600 / 60)m"
        }
    }

    /// Formatted elapsed time string
    var elapsedTimeString: String {
        let seconds = Int(elapsedTime)
        if seconds < 60 {
            return "\(seconds)s"
        } else if seconds < 3600 {
            return "\(seconds / 60)m \(seconds % 60)s"
        } else {
            return "\(seconds / 3600)h \(seconds % 3600 / 60)m"
        }
    }

    /// All phases in order
    static let allPhases: [AnalysisPhase] = [
        .loadingFiles,
        .parsingWikiLinks,
        .analyzingContent,
        .buildingGraph,
        .identifyingClusters,
        .generatingSuggestions
    ]

    /// Index of current phase (0-based)
    var currentPhaseIndex: Int {
        Self.allPhases.firstIndex(of: currentPhase) ?? 0
    }

    /// Total number of phases
    var totalPhases: Int {
        Self.allPhases.count
    }

    // MARK: - Init

    init(
        totalNotes: Int = 0,
        processedNotes: Int = 0,
        currentPhase: AnalysisPhase = .idle,
        startedAt: Date = Date(),
        estimatedCompletion: Date? = nil,
        errors: [AnalysisError] = [],
        currentBatch: Int = 0,
        totalBatches: Int = 0,
        phaseStartTimes: [String: Date] = [:],
        phaseEndTimes: [String: Date] = [:],
        completedPhases: [AnalysisPhase] = [],
        notesPerSecond: Double = 0,
        batchesPerMinute: Double = 0,
        lastThroughputUpdate: Date? = nil,
        currentBatchNotes: [String] = [],
        connectionsFoundThisBatch: Int = 0,
        totalConnectionsFound: Int = 0,
        statusMessage: String? = nil,
        currentNotePath: String? = nil
    ) {
        self.totalNotes = totalNotes
        self.processedNotes = processedNotes
        self.currentPhase = currentPhase
        self.startedAt = startedAt
        self.estimatedCompletion = estimatedCompletion
        self.errors = errors
        self.currentBatch = currentBatch
        self.totalBatches = totalBatches
        self.phaseStartTimes = phaseStartTimes
        self.phaseEndTimes = phaseEndTimes
        self.completedPhases = completedPhases
        self.notesPerSecond = notesPerSecond
        self.batchesPerMinute = batchesPerMinute
        self.lastThroughputUpdate = lastThroughputUpdate
        self.currentBatchNotes = currentBatchNotes
        self.connectionsFoundThisBatch = connectionsFoundThisBatch
        self.totalConnectionsFound = totalConnectionsFound
        self.statusMessage = statusMessage
        self.currentNotePath = currentNotePath
    }

    // MARK: - Mutation Methods

    /// Start a new phase
    mutating func startPhase(_ phase: AnalysisPhase) {
        // End current phase if active
        if currentPhase != .idle && phaseStartTimes[currentPhase.rawValue] != nil {
            phaseEndTimes[currentPhase.rawValue] = Date()
            if !completedPhases.contains(currentPhase) {
                completedPhases.append(currentPhase)
            }
        }

        currentPhase = phase
        phaseStartTimes[phase.rawValue] = Date()
        statusMessage = phase.displayName
    }

    /// Update throughput metrics
    mutating func updateThroughput(notesProcessed: Int, timeInterval: TimeInterval) {
        guard timeInterval > 0 else { return }
        notesPerSecond = Double(notesProcessed) / timeInterval
        lastThroughputUpdate = Date()
    }

    /// Update batch throughput
    mutating func updateBatchThroughput(batchesCompleted: Int, timeInterval: TimeInterval) {
        guard timeInterval > 0 else { return }
        batchesPerMinute = Double(batchesCompleted) / (timeInterval / 60.0)
    }

    /// Set current batch info
    mutating func setCurrentBatch(notes: [String], batchNumber: Int) {
        currentBatchNotes = notes
        currentBatch = batchNumber
        connectionsFoundThisBatch = 0
    }

    /// Record a connection found
    mutating func recordConnection() {
        connectionsFoundThisBatch += 1
        totalConnectionsFound += 1
    }

    /// Record multiple connections
    mutating func recordConnections(_ count: Int) {
        connectionsFoundThisBatch += count
        totalConnectionsFound += count
    }

    /// Mark analysis as complete
    mutating func markComplete() {
        if currentPhase != .idle {
            phaseEndTimes[currentPhase.rawValue] = Date()
            if !completedPhases.contains(currentPhase) {
                completedPhases.append(currentPhase)
            }
        }
        currentPhase = .complete
        statusMessage = "Analysis complete"
        currentBatchNotes = []
    }

    /// Mark analysis as failed
    mutating func markFailed(error: String) {
        currentPhase = .failed
        statusMessage = error
    }
}

/// Analysis error tracking
struct AnalysisError: Codable, Identifiable {
    let id: UUID
    let notePath: String
    let error: String
    let timestamp: Date
    let phase: AnalysisPhase

    init(
        id: UUID = UUID(),
        notePath: String,
        error: String,
        timestamp: Date = Date(),
        phase: AnalysisPhase
    ) {
        self.id = id
        self.notePath = notePath
        self.error = error
        self.timestamp = timestamp
        self.phase = phase
    }


Perfect! Now let me check how the analysisProgress is bound in the SmartConnectionService:


grep -n "analysisProgress\|@Published" /Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/SmartConnectionService.swift | head -30


20:    @Published var vaultGraph: VaultGraph?
21:    @Published var pendingSuggestions: [ConnectionSuggestion] = []
22:    @Published var analysisProgress: AnalysisProgress?
23:    @Published var feedbackMetrics: FeedbackMetrics
24:    @Published var learningWeights: LearningWeights
25:    @Published var isAnalyzing: Bool = false
26:    @Published var lastError: String?
84:        analysisProgress = progress
88:            if let elapsed = analysisProgress?.elapsedTime {
96:            analysisProgress?.statusMessage = "Loading files from vault..."
98:            analysisProgress?.totalNotes = markdownFiles.count
99:            analysisProgress?.statusMessage = "Found \(markdownFiles.count) notes"
104:            analysisProgress?.startPhase(.parsingWikiLinks)
105:            analysisProgress?.statusMessage = "Parsing wiki-links and metadata..."
126:                    analysisProgress?.statusMessage = "Loading batch \(batchIndex + 1)/\(fileBatches.count)..."
138:                        analysisProgress?.currentNotePath = file.path
183:                    // Batch UI updates: only update @Published property every N files
185:                        analysisProgress?.processedNotes = localProcessedCount
186:                        analysisProgress?.totalConnectionsFound = localConnectionCount
193:                analysisProgress?.updateThroughput(notesProcessed: localProcessedCount, timeInterval: elapsed)
197:            analysisProgress?.processedNotes = localProcessedCount
198:            analysisProgress?.totalConnectionsFound = localConnectionCount
202:            analysisProgress?.updateThroughput(notesProcessed: markdownFiles.count, timeInterval: parsingElapsed)
207:            analysisProgress?.startPhase(.analyzingContent)
208:            analysisProgress?.processedNotes = 0
217:            analysisProgress?.startPhase(.buildingGraph)
218:            analysisProgress?.statusMessage = "Building knowledge graph..."
224:            analysisProgress?.startPhase(.identifyingClusters)
225:            analysisProgress?.statusMessage = "Identifying topic clusters..."
231:            analysisProgress?.startPhase(.generatingSuggestions)


Excellent! Let me get the complete picture by checking the banner display more carefully:


   159→    // MARK: - Live Analysis Banner
   160→
   161→    private func liveAnalysisBanner(_ progress: AnalysisProgress) -> some View {
   162→        Button {
   163→            showLiveAnalysisSheet = true
   164→        } label: {
   165→            VStack(spacing: 10) {
   166→                // Header with phase and metrics
   167→                HStack {
   168→                    // Animated phase icon
   169→                    ZStack {
   170→                        Circle()
   171→                            .fill(progress.currentPhase.color.opacity(0.2))
   172→                            .frame(width: 36, height: 36)
   173→
   174→                        Image(systemName: progress.currentPhase.icon)
   175→                            .font(.system(size: 16))
   176→                            .foregroundColor(progress.currentPhase.color)
   177→                    }
   178→
   179→                    VStack(alignment: .leading, spacing: 2) {
   180→                        Text(progress.currentPhase.displayName)
   181→                            .font(.subheadline)
   182→                            .fontWeight(.medium)
   183→                            .foregroundColor(.primary)
   184→
   185→                        if let status = progress.statusMessage {
   186→                            Text(status)
   187→                                .font(.caption2)
   188→                                .foregroundColor(.secondary)
   189→                                .lineLimit(1)
   190→                        }
   191→                    }
   192→
   193→                    Spacer()
   194→
   195→                    // Metrics
   196→                    VStack(alignment: .trailing, spacing: 2) {
   197→                        Text("\(Int(progress.percentComplete * 100))%")
   198→                            .font(.headline)
   199→                            .foregroundColor(.purple)
   200→
   201→                        if let eta = progress.etaString {
   202→                            Text("ETA: \(eta)")
   203→                                .font(.caption2)
   204→                                .foregroundColor(.secondary)
   205→                        }
   206→                    }
   207→
   208→                    Image(systemName: "chevron.right")
   209→                        .font(.caption)
   210→                        .foregroundColor(.secondary)
   211→                }
   212→
   213→                // Progress bar with gradient
   214→                GeometryReader { geo in
   215→                    ZStack(alignment: .leading) {
   216→                        RoundedRectangle(cornerRadius: 4)
   217→                            .fill(Color(.systemGray5))
   218→
   219→                        RoundedRectangle(cornerRadius: 4)
   220→                            .fill(
   221→                                LinearGradient(
   222→                                    colors: [.purple, .blue],
   223→                                    startPoint: .leading,
   224→                                    endPoint: .trailing
   225→                                )
   226→                            )
   227→                            .frame(width: geo.size.width * progress.percentComplete)
   228→                            .animation(.easeInOut(duration: 0.3), value: progress.percentComplete)
   229→                    }
   230→                }
   231→                .frame(height: 8)
   232→
   233→                // Compact timeline
   234→                CompactPhaseTimelineView(progress: progress)
   235→
   236→                // Quick stats
   237→                HStack(spacing: 16) {
   238→                    Label("\(progress.processedNotes)/\(progress.totalNotes)", systemImage: "doc.text")
   239→                        .font(.caption2)
   240→                        .foregroundColor(.secondary)
   241→
   242→                    Label("\(progress.totalConnectionsFound)", systemImage: "link")
   243→                        .font(.caption2)
   244→                        .foregroundColor(.green)
   245→
   246→                    if progress.totalBatches > 0 {
   247→                        Label("\(progress.currentBatch)/\(progress.totalBatches)", systemImage: "square.stack.3d.up")
   248→                            .font(.caption2)
   249→                            .foregroundColor(.secondary)
   250→                    }
   251→
   252→                    Spacer()
   253→
   254→                    Text(progress.elapsedTimeString)
   255→                        .font(.caption2)
   256→                        .foregroundColor(.secondary)
   257→                }
   258→            }
   259→            .padding()
   260→            .background(Color.purple.opacity(0.1))
   261→        }
   262→        .buttonStyle(.plain)
   263→    }
   264→
   265→    // MARK: - Analyze Button
   266→
   267→    private var analyzeButton: some View {
   268→        Button {

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

    // MARK: - Live Analysis Banner

    private func liveAnalysisBanner(_ progress: AnalysisProgress) -> some View {
        Button {
            showLiveAnalysisSheet = true
        } label: {
            VStack(spacing: 10) {
                // Header with phase and metrics
                HStack {
                    // Animated phase icon
                    ZStack {
                        Circle()
                            .fill(progress.currentPhase.color.opacity(0.2))
                            .frame(width: 36, height: 36)

                        Image(systemName: progress.currentPhase.icon)
                            .font(.system(size: 16))
                            .foregroundColor(progress.currentPhase.color)
                    }

                    VStack(alignment: .leading, spacing: 2) {
                        Text(progress.currentPhase.displayName)
                            .font(.subheadline)
                            .fontWeight(.medium)
                            .foregroundColor(.primary)

                        if let status = progress.statusMessage {
                            Text(status)
                                .font(.caption2)
                                .foregroundColor(.secondary)
                                .lineLimit(1)
                        }
                    }

                    Spacer()

                    // Metrics
                    VStack(alignment: .trailing, spacing: 2) {
                        Text("\(Int(progress.percentComplete * 100))%")
                            .font(.headline)
                            .foregroundColor(.purple)

                        if let eta = progress.etaString {
                            Text("ETA: \(eta)")
                                .font(.caption2)
                                .foregroundColor(.secondary)
                        }
                    }

                    Image(systemName: "chevron.right")
                        .font(.caption)
                        .foregroundColor(.secondary)
                }

                // Progress bar with gradient
                GeometryReader { geo in
                    ZStack(alignment: .leading) {
                        RoundedRectangle(cornerRadius: 4)
                            .fill(Color(.systemGray5))

                        RoundedRectangle(cornerRadius: 4)
                            .fill(
                                LinearGradient(
                                    colors: [.purple, .blue],
                                    startPoint: .leading,
                                    endPoint: .trailing
                                )
                            )
                            .frame(width: geo.size.width * progress.percentComplete)
                            .animation(.easeInOut(duration: 0.3), value: progress.percentComplete)
                    }
                }
                .frame(height: 8)

                // Compact timeline
                CompactPhaseTimelineView(progress: progress)

                // Quick stats
                HStack(spacing: 16) {
                    Label("\(progress.processedNotes)/\(progress.totalNotes)", systemImage: "doc.text")
                        .font(.caption2)
                        .foregroundColor(.secondary)

                    Label("\(progress.totalConnectionsFound)", systemImage: "link")
                        .font(.caption2)
                        .foregroundColor(.green)

                    if progress.totalBatches > 0 {
                        Label("\(progress.currentBatch)/\(progress.totalBatches)", systemImage: "square.stack.3d.up")
                            .font(.caption2)
                            .foregroundColor(.secondary)
                    }

                    Spacer()

                    Text(progress.elapsedTimeString)
                        .font(.caption2)
                        .foregroundColor(.secondary)
                }
            }
            .padding()
            .background(Color.purple.opacity(0.1))
        }
        .buttonStyle(.plain)
    }

    // MARK: - Analyze Button

    private var analyzeButton: some View {
        Button {


Perfect! Now I have all the information I need. Let me compile a comprehensive report of my findings.

## Exploration Complete: Analysis Progress Display Components

I've thoroughly explored the Views/SmartConnections/ directory. Here's what I found:

### Key Files Involved in Progress Display

1. **`/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Views/SmartConnections/ConnectionsTabView.swift`**
   - Main tab view that orchestrates progress display
   - Displays `liveAnalysisBanner` when `connectionService.isAnalyzing` is true
   - Contains two progress display modes:
     - **Banner** (inline on tab view) - compact, always visible
     - **Sheet** (full-screen modal) - detailed, opened via button

2. **`/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Views/SmartConnections/Components/LiveAnalysisView.swift`**
   - Full-screen progress dashboard shown in sheet
   - Displays comprehensive real-time metrics and phase information
   - Shows current batch being processed and error details

3. **`/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Views/SmartConnections/Components/PhaseTimelineView.swift`**
   - Visual timeline showing analysis phases
   - Two variants:
     - `PhaseTimelineView` - full vertical timeline with phase durations
     - `CompactPhaseTimelineView` - horizontal timeline (used in banner)

4. **`/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Models/GraphModels.swift`**
   - Defines `AnalysisProgress` struct - central data model
   - Defines `AnalysisPhase` enum with display properties
   - Contains timing calculations and status formatting

5. **`/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/SmartConnectionService.swift`**
   - Updates `@Published var analysisProgress: AnalysisProgress?`
   - Observes real-time progress changes throughout analysis

---

### How Progress Display Works

**Data Binding:**
- `SmartConnectionService` holds `@Published analysisProgress` property
- Views observe via `@EnvironmentObject connectionService`
- Updates trigger UI refreshes automatically

**Display Hierarchy:**

```
ConnectionsTabView
├── Live Analysis Banner (visible when analyzing)
│   ├── Phase icon + display name
│   ├── Status message (e.g., "Processing batch 4...")
│   ├── Progress percentage + ETA
│   ├── Progress bar with gradient
│   ├── Compact phase timeline
│   └── Quick stats (notes, connections, batches, elapsed time)
│
├── Sheet (opened via banner button)
│   └── LiveAnalysisView (full detailed view)
│       ├── Phase header with elapsed time
│       ├── Large progress bar with percentage
│       ├── Metrics grid (4 columns)
│       ├── Full phase timeline with durations
│       ├── Current batch details (notes being processed)
│       ├── Error section (if any errors)
│       └── Cancel button
```

---

### Current Visual Feedback Components

**1. Phase Header** (Lines 179-190 in ConnectionsTabView)
- Animated icon showing current phase
- Phase display name
- Status message from analysis (e.g., "Analyzing content...")

**2. Progress Bar** (Lines 213-231)
- Gradient fill from purple → blue
- Smooth animation (0.3s easing)
- Height: 8pt in banner, 12pt in sheet
- Shows percentage text above

**3. Phase Timeline** (Lines 233-234)
- Compact horizontal version in banner
- Shows completed/current/pending phases
- Color coding: green (completed), purple (current), gray (pending)

**4. Quick Stats** (Lines 237-257)
- Notes processed: `processedNotes/totalNotes`
- Connections found: total count with green color
- Batch progress: `currentBatch/totalBatches`
- Elapsed time with secondary color

**5. Metrics Grid** (in LiveAnalysisView, lines 157-192)
- Speed: `notesPerSecond` with speedometer icon
- Connections: count with link icon
- Batches: `currentBatch/totalBatches` with stack icon
- Errors: count with status icon (green checkmark or red warning)

**6. Animations**
- Phase icon in banner: scales (0.9 → 1.1) continuously
- Compact phase indicators: pulse effect when current
- Full phase timeline: animated connector lines and scaling circles

---

### AnalysisProgress Model Structure

The `AnalysisProgress` struct (GraphModels.swift, lines 146-378) contains:

**Core Metrics:**
- `totalNotes`, `processedNotes`, `currentPhase`
- `currentBatch`, `totalBatches`
- `totalConnectionsFound`, `connectionsFoundThisBatch`

**Phase Tracking:**
- `phaseStartTimes`, `phaseEndTimes` (dictionaries)
- `completedPhases` array
- `statusMessage`, `currentNotePath`

**Throughput Metrics:**
- `notesPerSecond`, `batchesPerMinute`
- ETA calculation via `estimatedSecondsRemaining`

**Computed Properties:**
- `percentComplete` - normalized progress (0.0 - 1.0)
- `elapsedTimeString` - formatted elapsed time
- `etaString` - formatted ETA
- `isActive` - whether analysis is ongoing
- `hasErrors` - error status flag

---

### Key Observation: "Analyzing content..." Phase

During the `analyzingContent` phase specifically:
- Display name: `"Analyzing content..."` (Line 111 in GraphModels.swift)
- Icon: Brain icon (🧠)
- Color: Blue
- Status message: Updated with batch info like `"Processing batch 4..."` (Lines 126, 138 in SmartConnectionService)
- The banner and sheet both show this exact phase with animations

The `statusMessage` is what gets updated with the "Analyzing content..." text and more specific details like batch numbers during this phase.