Advertisement

← poker-tutor

Sources/PokerUI/AnalysisViews.swift

19.6 KB Download file
import SwiftUI
import PokerCore
import PokerCoach
import PokerSession

/// Picks which hands the screen is about: this sitting, everything, or one particular sitting.
struct ScopeBar: View {
    @Environment(AppState.self) var app
    @Environment(\.metrics) var metrics

    var body: some View {
        HStack(spacing: 10) {
            Menu {
                Button("This session") { app.scope = .thisSession }
                Button("All time") { app.scope = .allTime }
                if !app.pastSessions.isEmpty {
                    Divider()
                    ForEach(app.pastSessions.prefix(20)) { summary in
                        Button(HistoryFilter.title(for: summary)) { app.scope = .past(summary.id) }
                    }
                }
            } label: {
                HStack(spacing: 6) {
                    // These two sit in the line of text, so they move with it.
                    Symbol("line.3.horizontal.decrease.circle", size: Theme.size(12))
                    Text(app.scopeName).caption(weight: .medium)
                    Symbol("chevron.down", size: Theme.size(9))
                }
                .foregroundStyle(Theme.text)
                .padding(.horizontal, 12)
                .frame(minHeight: metrics.compact ? 38 : 30)
                .background(RoundedRectangle(cornerRadius: 9, style: .continuous).fill(Theme.panelRaised))
            }
            .plainMenuStyle()
            .fixedSize()

            Text(Copy.hands(app.scopedHands.count))
                .caption().foregroundStyle(Theme.dim)
            Spacer()
        }
    }
}

/// The headline numbers, in the vocabulary a poker player already uses.
///
/// These four names are the one place the plain-English rule cannot be kept: VPIP and the rest have
/// no plain equivalent, and renaming them would make the numbers incomparable with what a player
/// reads anywhere else. So the label is tappable instead, and the definition opens under the row.
struct AnalysisSummary: View {
    let analysis: GameAnalysis
    @Environment(\.metrics) var metrics
    @State var showing: GlossaryTerm?

    var body: some View {
        VStack(alignment: .leading, spacing: 14) {
            // With no hands at all these were three noughts and a "+0.0 bb", printed in the same
            // confident type as a real result — and the sentence that would have explained them
            // sat inside a branch that only ran once there were hands, so it could never appear
            // beside them. Nought reads as "this is free", which is the one thing this screen
            // refuses to imply anywhere else on it.
            let empty = analysis.hands == 0
            grid([
                ("Hands", nil, "\(analysis.hands)", Theme.text),
                ("Won", nil, analysis.netChipsText,
                 empty ? Theme.dim : (analysis.netChips >= 0 ? Theme.accent : Theme.warn)),
                ("Per 100 hands", "bb/100", analysis.bbPer100Text,
                 empty ? Theme.dim : (analysis.bbPer100 >= 0 ? Theme.accent : Theme.warn)),
                // Named for the decisions it is actually summing. The habits at the top of this
                // screen count the cheap decisions the coach stays quiet on as well, so their
                // total is always larger — two numbers both called "what your mistakes cost", a
                // few inches apart, over different sets of decisions.
                ("Flagged mistakes", "bb/100", analysis.costPer100Text,
                 empty ? Theme.dim : Theme.gold),
            ])

            if !empty {
                Divider().overlay(Color.white.opacity(0.06))
                grid([
                    ("VPIP", "VPIP", rate(analysis.vpip), Theme.text),
                    ("PFR", "PFR", rate(analysis.pfr), Theme.text),
                    ("To showdown", "went to showdown", rate(analysis.wentToShowdown), Theme.text),
                    ("Won at showdown", "won at showdown", rate(analysis.wonAtShowdown), Theme.text),
                ])
            }
            GlossaryText(footnote, size: TextRole.caption.base, showing: $showing)
            DefinitionSlot(showing: $showing)
        }
    }

    /// The arranged hands are handed over as well as the dealt ones. Without them this panel read
    /// "HANDS 0" and "nothing has been played" straight after a forty-hand assessment, which is the
    /// first thing anybody does.
    private var footnote: String {
        Copy.statisticsFootnote(hands: analysis.hands,
                                arranged: analysis.assessmentHands + analysis.drillHands,
                                enough: analysis.enoughHands)
    }

    private func rate(_ value: GameAnalysis.Rate) -> String {
        value.of == 0 ? "—" : "\(value.percent)%"
    }

    private func grid(_ items: [(String, String?, String, Color)]) -> some View {
        LazyVGrid(columns: Array(repeating: GridItem(.flexible(), alignment: .leading),
                                 count: metrics.compact ? 2 : 4), spacing: 12) {
            ForEach(items, id: \.0) { title, term, value, colour in
                VStack(alignment: .leading, spacing: 2) {
                    if let term {
                        TermChip(term, showing: $showing, display: title.uppercased(), size: TextRole.caption.base)
                    } else {
                        Text(title.uppercased()).caption(weight: .medium).foregroundStyle(Theme.dim)
                    }
                    Text(value).numeric(metrics.compact ? 16 : 18).foregroundStyle(colour)
                }
            }
        }
    }
}

/// Where the money actually comes from and goes to.
///
/// The seat number leads and the old name sits under it, because "UTG+1" is a fact about a table
/// the player cannot see. Picking a row draws that table and says, in words, how many people still
/// get to act after them from there — which is the thing the name was standing in for all along.
struct PositionTable: View {
    let analysis: GameAnalysis
    @Environment(AppState.self) var app
    @State var chosen: String?

    /// Ken, 2026-08-23: "on the my game screen, the text scrolls too far over to the right and cant
    /// be fully read."
    ///
    /// Every column here used to have a fixed width multiplied by the player's text-size setting.
    /// Four of those add up to 270 points at Normal, and a phone has about 307 to give once the
    /// screen's margins and the panel's padding are taken out — so it fitted, barely, and then
    /// stopped fitting the moment somebody turned the type up. Which is exactly what somebody who
    /// finds a table hard to read does.
    ///
    /// The seat column takes whatever is left instead of a width of its own, so the row can never
    /// be wider than the screen. Its text is the only thing here that can afford to wrap or shorten
    /// — "Seat 2 of 9" over "Big blind" — where a number that loses its last digit is worse than
    /// useless, since it still looks like a number.
    ///
    /// The three number columns are capped: they grow with the setting up to Large and no further,
    /// the same rule the seat boxes on the felt already follow, because past that the columns eat
    /// the seat name whole.
    /// The widths themselves live on `Metrics`, with the rest of the sizes that depend on the
    /// space available — which is what lets the suite check that a row fits a phone at every text
    /// size, rather than it being found by somebody reading their own stats.
    @Environment(\.metrics) var metrics

    private var selected: GameAnalysis.PositionStat? {
        analysis.byPosition.first { $0.id == chosen } ?? analysis.byPosition.first
    }

    var body: some View {
        VStack(spacing: 8) {
            HStack(spacing: metrics.statGap) {
                Text("SEAT").caption(weight: .medium).foregroundStyle(Theme.dim)
                    .frame(maxWidth: .infinity, alignment: .leading)
                Text("HANDS").caption(weight: .medium).foregroundStyle(Theme.dim)
                    .frame(width: metrics.statHands, alignment: .trailing)
                Text("PLAYED").caption(weight: .medium).foregroundStyle(Theme.dim)
                    .frame(width: metrics.statPlayed, alignment: .trailing)
                Text("PER 100").caption(weight: .medium).foregroundStyle(Theme.dim)
                    .frame(width: metrics.statPerHundred, alignment: .trailing)
            }
            ForEach(analysis.byPosition) { row in
                Button { chosen = row.id } label: { line(row) }
                    .buttonStyle(.plain)
            }
            if analysis.byPosition.contains(where: { $0.hands < 25 }) {
                HStack {
                    Text(Copy.thinSeatRows)
                        .caption().foregroundStyle(Theme.dim.opacity(0.8))
                    Spacer()
                }
            }
            if let selected {
                Divider().overlay(Color.white.opacity(0.06))
                SeatExplainer(players: selected.tableSize,
                              seat: Position(rawValue: selected.position))
                // Any seat can be drilled, not only the one the diagnosis names: a player who
                // knows which seat they are bad at should not have to wait for the hands to
                // prove it before practising there.
                if let seat = Position(rawValue: selected.position) {
                    HStack {
                        GhostButton(title: app.mayDrillSeat(seat)
                                        ? Copy.drillSeatTitle(seat.seatPhrase(inTableOf: selected.tableSize))
                                        : Copy.seatDrillLocked,
                                    wide: metrics.compact) {
                            app.startOrResumeSeatDrill(seat)   // shows the price where locked
                        }
                        if !metrics.compact { Spacer() }
                    }
                }
            }
        }
    }

    private func line(_ row: GameAnalysis.PositionStat) -> some View {
        HStack(spacing: metrics.statGap) {
            VStack(alignment: .leading, spacing: 1) {
                Text(row.seatNumber.map { "Seat \($0) of \(row.tableSize)" } ?? row.position)
                    .numericSmall().foregroundStyle(Theme.text)
                Text(row.fullName).caption(weight: .medium).foregroundStyle(Theme.dim)
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            Text("\(row.hands)").numericSmall(weight: .regular).foregroundStyle(Theme.dim)
                .frame(width: metrics.statHands, alignment: .trailing)
            Text(row.vpip.of == 0 ? "—" : "\(row.vpip.percent)%")
                .numericSmall(weight: .regular).foregroundStyle(Theme.dim)
                .frame(width: metrics.statPlayed, alignment: .trailing)
            Text(String(format: "%+.1f bb", row.bbPer100))
                .numericSmall()
                .foregroundStyle(row.hands < 25 ? Theme.dim : (row.bbPer100 >= 0 ? Theme.accent : Theme.warn))
                .frame(width: metrics.statPerHundred, alignment: .trailing)
        }
        .padding(.vertical, 4)
        .padding(.horizontal, 6)
        .background(RoundedRectangle(cornerRadius: 7, style: .continuous)
            .fill(selected?.id == row.id ? Theme.panelRaised : Color.clear))
        .contentShape(Rectangle())
    }
}

/// Which decisions cost the money, and on which street.
///
/// The rows are the scorecard's, and that is the point of them: the panel above this one and this
/// one used to count the same mistakes separately, under different names, a few inches apart. One
/// habit, one name, one number. A cost the coach could not work out says so rather than showing a
/// nought, which would read as free.
struct CostBreakdown: View {
    let scorecard: LeakScorecard
    let analysis: GameAnalysis
    @State var showing: GlossaryTerm?

    /// Every habit that has actually gone wrong in these hands, dearest first. Priced ones lead;
    /// an unpriced one sits below them rather than being given a nought and ranked as harmless.
    private var rows: [LeakScorecard.Entry] {
        scorecard.allEntries.filter { $0.times > 0 }.sorted { a, b in
            switch (a.costInBB, b.costInBB) {
            case let (x?, y?): return x != y ? x > y : a.times > b.times
            case (.some, .none): return true
            case (.none, .some): return false
            case (.none, .none): return a.times > b.times
            }
        }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 14) {
            if rows.isEmpty {
                Text(Copy.noFlaggedHands)
                    .body().foregroundStyle(Theme.dim).fixedSize(horizontal: false, vertical: true)
            } else {
                VStack(spacing: 9) {
                    ForEach(rows.prefix(6)) { entry in
                        HStack(spacing: 10) {
                            Text(entry.leak.name).body().foregroundStyle(Theme.text)
                                .frame(maxWidth: .infinity, alignment: .leading)
                            Text("\(entry.times)×").numericSmall().foregroundStyle(Theme.dim)
                                .frame(width: Theme.size(40), alignment: .trailing)
                            Text(entry.costInBB.map { String(format: "%.1f bb", $0) } ?? "not known")
                                .numericSmall()
                                .foregroundStyle(entry.costIsKnown ? Theme.warn : Theme.dim)
                                .frame(width: Theme.size(68), alignment: .trailing)
                        }
                    }
                }

                if !analysis.byStreet.isEmpty {
                    Divider().overlay(Color.white.opacity(0.06))
                    HStack(spacing: 18) {
                        ForEach(analysis.byStreet) { street in
                            VStack(alignment: .leading, spacing: 2) {
                                // "Preflop" and "the turn" are rounds of betting, not English —
                                // which is why the round is the tappable part of the tile.
                                TermChip(street.street, showing: $showing,
                                         display: street.street.uppercased(), size: 9)
                                Text(String(format: "%.1f bb", street.costBB))
                                    .numeric().foregroundStyle(Theme.gold)
                            }
                        }
                        Spacer()
                    }
                    DefinitionSlot(showing: $showing)
                }

                // The two halves of this panel count different hands, and they have to: how often
                // a habit happens is measured over assessment hands and what it cost is not.
                //
                // This used to sit inside the branch above, which draws nothing at all when every
                // hand in view was arranged — so the explanation appeared in every case where it
                // was barely needed and was missing from the one case it exists for, where the
                // rows above are made entirely of arranged hands.
                if analysis.assessmentHands > 0 {
                    Text(Copy.roundsLeaveOutArranged(analysis.assessmentHands))
                        .caption().foregroundStyle(Theme.dim)
                        .fixedSize(horizontal: false, vertical: true)
                }
            }
        }
    }
}

/// The two hands worth looking at again.
struct NotableHands: View {
    let analysis: GameAnalysis
    let open: (HandRecord) -> Void

    var body: some View {
        VStack(spacing: 10) {
            if let best = analysis.best, best.netChips > 0 { row("Biggest win", best, Theme.accent) }
            if let worst = analysis.worst, worst.netChips < 0 { row("Worst hand", worst, Theme.warn) }
        }
    }

    private func row(_ title: String, _ hand: HandRecord, _ colour: Color) -> some View {
        Button { open(hand) } label: {
            HStack(spacing: 10) {
                Text(title).body().foregroundStyle(Theme.dim)
                    .frame(width: Theme.size(96), alignment: .leading)
                HStack(spacing: 3) {
                    ForEach(Array(hand.cards.enumerated()), id: \.offset) { _, card in
                        CardView(card: card, size: 26)
                    }
                }
                Text(hand.seatPhrase).caption(weight: .medium).foregroundStyle(Theme.dim)
                Spacer()
                Text(hand.netChips >= 0 ? "+\(hand.netChips)" : "\(hand.netChips)")
                    .numeric().foregroundStyle(colour)
            }
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
    }
}


/// Which seat the player loses most from, and why — and the drill that sits them there.
///
/// Ken, 2026-09-11: "a mode that diagnoses which position they play the worst in and why and let
/// them drill while playing only that position." The diagnosis is shown to everyone: the by-seat
/// table it is read from already is. The drill is gated like every other drill.
struct WhereYouLoseMost: View {
    @Environment(AppState.self) var app
    @Environment(\.metrics) var metrics
    @State var showing: GlossaryTerm?

    var body: some View {
        let diagnosis = app.seatDiagnosis
        Panel(title: Copy.whereYouLoseMost, subtitle: Copy.whereYouLoseMostSubtitle) {
            if let worst = diagnosis.worst, let headline = diagnosis.headline, let seat = worst.seat {
                GlossaryText(headline, size: TextRole.body.base, colour: Theme.text, showing: $showing)
                if let evidence = diagnosis.evidenceLine {
                    Text(evidence).caption().foregroundStyle(Theme.dim)
                        .fixedSize(horizontal: false, vertical: true)
                }
                if !worst.leading.isEmpty {
                    VStack(alignment: .leading, spacing: 4) {
                        ForEach(worst.leading, id: \.leak.id) { item in
                            HStack(alignment: .firstTextBaseline, spacing: 6) {
                                Symbol("target", size: Theme.size(10, capped: true)).foregroundStyle(Theme.accent)
                                Text("\(item.leak.name) — \(item.times) \(item.times == 1 ? "time" : "times")")
                                    .caption(weight: .medium).foregroundStyle(Theme.accent.opacity(0.9))
                            }
                        }
                    }
                }
                if let standing = app.drills.standing(ofSeat: seat) {
                    Text(standing.badge(now: app.drills.now)).caption(weight: .medium).foregroundStyle(Theme.gold)
                }
                DefinitionSlot(showing: $showing)
                HStack(spacing: 10) {
                    PillButton(title: app.mayDrillSeat(seat) ? Copy.seatDrillTitle : Copy.seatDrillLocked,
                               wide: metrics.compact) {
                        app.startOrResumeSeatDrill(seat)
                    }
                    if !metrics.compact { Spacer() }
                }
            } else {
                // The honest answer, with the number that would change it.
                Text(diagnosis.reason ?? "").body().foregroundStyle(Theme.dim)
                    .fixedSize(horizontal: false, vertical: true)
            }
        }
    }
}