415 lines
11 KiB
Go
415 lines
11 KiB
Go
package screens
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"github.com/santifer/career-ops/dashboard/internal/i18n"
|
|
"github.com/santifer/career-ops/dashboard/internal/model"
|
|
"github.com/santifer/career-ops/dashboard/internal/theme"
|
|
)
|
|
|
|
// ProgressClosedMsg is emitted when the progress screen is dismissed.
|
|
type ProgressClosedMsg struct{}
|
|
|
|
// ProgressModel implements the progress analytics screen.
|
|
type ProgressModel struct {
|
|
metrics model.ProgressMetrics
|
|
scrollOffset int
|
|
width int
|
|
height int
|
|
theme theme.Theme
|
|
}
|
|
|
|
// NewProgressModel creates a new progress screen.
|
|
func NewProgressModel(t theme.Theme, metrics model.ProgressMetrics, width, height int) ProgressModel {
|
|
return ProgressModel{
|
|
metrics: metrics,
|
|
width: width,
|
|
height: height,
|
|
theme: t,
|
|
}
|
|
}
|
|
|
|
// Init implements tea.Model.
|
|
func (m ProgressModel) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
// Resize updates dimensions.
|
|
func (m *ProgressModel) Resize(width, height int) {
|
|
m.width = width
|
|
m.height = height
|
|
}
|
|
|
|
// Update handles input for the progress screen.
|
|
func (m ProgressModel) Update(msg tea.Msg) (ProgressModel, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "q", "esc":
|
|
return m, func() tea.Msg { return ProgressClosedMsg{} }
|
|
case "down", "j":
|
|
m.scrollOffset++
|
|
case "up", "k":
|
|
if m.scrollOffset > 0 {
|
|
m.scrollOffset--
|
|
}
|
|
case "pgdown", "ctrl+d":
|
|
m.scrollOffset += m.height / 2
|
|
case "pgup", "ctrl+u":
|
|
m.scrollOffset -= m.height / 2
|
|
if m.scrollOffset < 0 {
|
|
m.scrollOffset = 0
|
|
}
|
|
}
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// View renders the progress screen.
|
|
func (m ProgressModel) View() string {
|
|
header := m.renderHeader()
|
|
funnel := m.renderFunnel()
|
|
scores := m.renderScoreDistribution()
|
|
rates := m.renderRates()
|
|
weekly := m.renderWeeklyActivity()
|
|
help := m.renderHelp()
|
|
|
|
// Combine panels
|
|
body := lipgloss.JoinVertical(lipgloss.Left,
|
|
funnel,
|
|
"",
|
|
scores,
|
|
"",
|
|
rates,
|
|
"",
|
|
weekly,
|
|
)
|
|
|
|
// Apply scroll
|
|
bodyLines := strings.Split(body, "\n")
|
|
offset := m.scrollOffset
|
|
if offset >= len(bodyLines) {
|
|
offset = len(bodyLines) - 1
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
if offset > 0 {
|
|
bodyLines = bodyLines[offset:]
|
|
}
|
|
|
|
// Clamp to available height
|
|
availHeight := m.height - 4 // header + help + padding
|
|
if availHeight < 3 {
|
|
availHeight = 3
|
|
}
|
|
if len(bodyLines) > availHeight {
|
|
bodyLines = bodyLines[:availHeight]
|
|
}
|
|
|
|
body = strings.Join(bodyLines, "\n")
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, header, body, help)
|
|
}
|
|
|
|
func (m ProgressModel) renderHeader() string {
|
|
style := lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(m.theme.Text).
|
|
Background(m.theme.Surface).
|
|
Width(m.width).
|
|
Padding(0, 2)
|
|
|
|
title := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Mauve).Render(i18n.Current.ProgressTitle)
|
|
|
|
right := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
|
total := len(m.metrics.FunnelStages)
|
|
totalCount := 0
|
|
if total > 0 {
|
|
totalCount = m.metrics.FunnelStages[0].Count
|
|
}
|
|
info := right.Render(fmt.Sprintf(i18n.Current.ProgressSummary, totalCount, m.metrics.AvgScore))
|
|
|
|
gap := m.width - lipgloss.Width(title) - lipgloss.Width(info) - 4
|
|
if gap < 1 {
|
|
gap = 1
|
|
}
|
|
|
|
return style.Render(title + strings.Repeat(" ", gap) + info)
|
|
}
|
|
|
|
func (m ProgressModel) renderFunnel() string {
|
|
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
|
sectionTitle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Sky)
|
|
|
|
var lines []string
|
|
lines = append(lines, padStyle.Render(sectionTitle.Render(i18n.Current.FunnelTitle)))
|
|
|
|
if len(m.metrics.FunnelStages) == 0 {
|
|
dimStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
|
lines = append(lines, padStyle.Render(dimStyle.Render(i18n.Current.NoData)))
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// Find max count for bar scaling
|
|
maxCount := 0
|
|
for _, s := range m.metrics.FunnelStages {
|
|
if s.Count > maxCount {
|
|
maxCount = s.Count
|
|
}
|
|
}
|
|
|
|
labelW := 10
|
|
barMaxW := m.width - labelW - 20 // room for label, count, pct
|
|
if barMaxW < 10 {
|
|
barMaxW = 10
|
|
}
|
|
|
|
// Colors for funnel stages (gradient from cool to warm)
|
|
stageColors := []lipgloss.Color{
|
|
m.theme.Blue,
|
|
m.theme.Sky,
|
|
m.theme.Green,
|
|
m.theme.Yellow,
|
|
m.theme.Peach,
|
|
}
|
|
|
|
for i, stage := range m.metrics.FunnelStages {
|
|
barW := 0
|
|
if maxCount > 0 {
|
|
barW = stage.Count * barMaxW / maxCount
|
|
}
|
|
if barW < 1 && stage.Count > 0 {
|
|
barW = 1
|
|
}
|
|
|
|
color := m.theme.Text
|
|
if i < len(stageColors) {
|
|
color = stageColors[i]
|
|
}
|
|
|
|
barStyle := lipgloss.NewStyle().Foreground(color)
|
|
labelStyle := lipgloss.NewStyle().Foreground(m.theme.Text).Width(labelW)
|
|
countStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
|
|
|
bar := barStyle.Render(strings.Repeat("\u2588", barW))
|
|
label := labelStyle.Render(stage.Label)
|
|
|
|
pctStr := ""
|
|
if i > 0 {
|
|
pctStr = fmt.Sprintf(" (%.0f%%)", stage.Pct)
|
|
}
|
|
count := countStyle.Render(fmt.Sprintf(" %d%s", stage.Count, pctStr))
|
|
|
|
lines = append(lines, padStyle.Render(label+bar+count))
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m ProgressModel) renderScoreDistribution() string {
|
|
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
|
sectionTitle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Sky)
|
|
|
|
var lines []string
|
|
lines = append(lines, padStyle.Render(sectionTitle.Render(i18n.Current.ScoresTitle)))
|
|
|
|
if len(m.metrics.ScoreBuckets) == 0 {
|
|
dimStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
|
lines = append(lines, padStyle.Render(dimStyle.Render(i18n.Current.NoData)))
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// Find max count for bar scaling
|
|
maxCount := 0
|
|
for _, b := range m.metrics.ScoreBuckets {
|
|
if b.Count > maxCount {
|
|
maxCount = b.Count
|
|
}
|
|
}
|
|
|
|
labelW := 8
|
|
barMaxW := m.width - labelW - 14
|
|
if barMaxW < 10 {
|
|
barMaxW = 10
|
|
}
|
|
|
|
// Colors for score ranges (green to red)
|
|
bucketColors := []lipgloss.Color{
|
|
m.theme.Green,
|
|
m.theme.Green,
|
|
m.theme.Yellow,
|
|
m.theme.Peach,
|
|
m.theme.Red,
|
|
}
|
|
|
|
for i, bucket := range m.metrics.ScoreBuckets {
|
|
barW := 0
|
|
if maxCount > 0 {
|
|
barW = bucket.Count * barMaxW / maxCount
|
|
}
|
|
if barW < 1 && bucket.Count > 0 {
|
|
barW = 1
|
|
}
|
|
|
|
color := m.theme.Text
|
|
if i < len(bucketColors) {
|
|
color = bucketColors[i]
|
|
}
|
|
|
|
barStyle := lipgloss.NewStyle().Foreground(color)
|
|
labelStyle := lipgloss.NewStyle().Foreground(m.theme.Text).Width(labelW)
|
|
countStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
|
|
|
bar := barStyle.Render(strings.Repeat("\u2588", barW))
|
|
label := labelStyle.Render(bucket.Label)
|
|
count := countStyle.Render(fmt.Sprintf(" %d", bucket.Count))
|
|
|
|
lines = append(lines, padStyle.Render(label+bar+count))
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m ProgressModel) renderRates() string {
|
|
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
|
sectionTitle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Sky)
|
|
|
|
var lines []string
|
|
lines = append(lines, padStyle.Render(sectionTitle.Render(i18n.Current.RatesTitle)))
|
|
|
|
labelStyle := lipgloss.NewStyle().Foreground(m.theme.Text)
|
|
valueStyle := lipgloss.NewStyle().Bold(true)
|
|
sepStyle := lipgloss.NewStyle().Foreground(m.theme.Overlay)
|
|
|
|
responseColor := m.rateColor(m.metrics.ResponseRate)
|
|
interviewColor := m.rateColor(m.metrics.InterviewRate)
|
|
offerColor := m.rateColor(m.metrics.OfferRate)
|
|
|
|
sep := sepStyle.Render(" | ")
|
|
|
|
rates := labelStyle.Render(i18n.Current.RateResponse) +
|
|
valueStyle.Foreground(responseColor).Render(fmt.Sprintf("%.1f%%", m.metrics.ResponseRate)) +
|
|
sep +
|
|
labelStyle.Render(i18n.Current.RateInterview) +
|
|
valueStyle.Foreground(interviewColor).Render(fmt.Sprintf("%.1f%%", m.metrics.InterviewRate)) +
|
|
sep +
|
|
labelStyle.Render(i18n.Current.RateOffer) +
|
|
valueStyle.Foreground(offerColor).Render(fmt.Sprintf("%.1f%%", m.metrics.OfferRate))
|
|
|
|
lines = append(lines, padStyle.Render(rates))
|
|
|
|
// Active summary
|
|
dimStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
|
activeInfo := dimStyle.Render(fmt.Sprintf(
|
|
i18n.Current.ActiveInfo,
|
|
m.metrics.ActiveApps, m.metrics.TotalOffers,
|
|
))
|
|
lines = append(lines, padStyle.Render(activeInfo))
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m ProgressModel) renderWeeklyActivity() string {
|
|
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
|
sectionTitle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Sky)
|
|
|
|
var lines []string
|
|
lines = append(lines, padStyle.Render(sectionTitle.Render(i18n.Current.WeeklyTitle)))
|
|
|
|
if len(m.metrics.WeeklyActivity) == 0 {
|
|
dimStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
|
lines = append(lines, padStyle.Render(dimStyle.Render(i18n.Current.NoData)))
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// Find max count for bar scaling
|
|
maxCount := 0
|
|
for _, w := range m.metrics.WeeklyActivity {
|
|
if w.Count > maxCount {
|
|
maxCount = w.Count
|
|
}
|
|
}
|
|
|
|
labelW := 10
|
|
barMaxW := m.width - labelW - 12
|
|
if barMaxW < 10 {
|
|
barMaxW = 10
|
|
}
|
|
|
|
for _, week := range m.metrics.WeeklyActivity {
|
|
barW := 0
|
|
if maxCount > 0 {
|
|
barW = week.Count * barMaxW / maxCount
|
|
}
|
|
if barW < 1 && week.Count > 0 {
|
|
barW = 1
|
|
}
|
|
|
|
barStyle := lipgloss.NewStyle().Foreground(m.theme.Blue)
|
|
labelStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext).Width(labelW)
|
|
countStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
|
|
|
// Show short week label (e.g., "W14" from "2026-W14")
|
|
shortWeek := week.Week
|
|
if idx := strings.Index(shortWeek, "-"); idx >= 0 {
|
|
shortWeek = shortWeek[idx+1:]
|
|
}
|
|
|
|
bar := barStyle.Render(strings.Repeat("\u2588", barW))
|
|
label := labelStyle.Render(shortWeek)
|
|
count := countStyle.Render(fmt.Sprintf(" %d", week.Count))
|
|
|
|
lines = append(lines, padStyle.Render(label+bar+count))
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m ProgressModel) renderHelp() string {
|
|
style := lipgloss.NewStyle().
|
|
Foreground(m.theme.Subtext).
|
|
Background(m.theme.Surface).
|
|
Width(m.width).
|
|
Padding(0, 1)
|
|
|
|
keyStyle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Text)
|
|
descStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
|
|
|
brand := lipgloss.NewStyle().Foreground(m.theme.Overlay).Render("career-ops by santifer.io")
|
|
|
|
keys := keyStyle.Render("↑↓") + descStyle.Render(i18n.Current.HelpScroll) +
|
|
keyStyle.Render("PgUp/Dn") + descStyle.Render(i18n.Current.HelpPage) +
|
|
keyStyle.Render("t") + descStyle.Render(i18n.Current.HelpLanguage) +
|
|
keyStyle.Render("Esc") + descStyle.Render(i18n.Current.HelpBack)
|
|
|
|
gap := m.width - lipgloss.Width(keys) - lipgloss.Width(brand) - 2
|
|
if gap < 1 {
|
|
gap = 1
|
|
}
|
|
|
|
return style.Render(keys + strings.Repeat(" ", gap) + brand)
|
|
}
|
|
|
|
// rateColor returns a color based on the rate value.
|
|
func (m ProgressModel) rateColor(rate float64) lipgloss.Color {
|
|
switch {
|
|
case rate >= 30:
|
|
return m.theme.Green
|
|
case rate >= 15:
|
|
return m.theme.Yellow
|
|
case rate >= 5:
|
|
return m.theme.Peach
|
|
default:
|
|
return m.theme.Red
|
|
}
|
|
}
|