352 lines
7.6 KiB
Go
352 lines
7.6 KiB
Go
package patterns
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
type Stats struct {
|
|
TotalApplications int
|
|
GhostRate float64
|
|
AvgScore float64
|
|
ScoreDistribution []Bucket
|
|
TopSources []SourceStat
|
|
TopArchetypes []ArchetypeStat
|
|
StatusBreakdown []StatusStat
|
|
HighScoreGhosts []Job
|
|
}
|
|
|
|
type Bucket struct {
|
|
Range string
|
|
Count int
|
|
}
|
|
|
|
type SourceStat struct {
|
|
Source string
|
|
Count int
|
|
AvgScore float64
|
|
}
|
|
|
|
type ArchetypeStat struct {
|
|
Archetype string
|
|
Count int
|
|
AvgScore float64
|
|
}
|
|
|
|
type StatusStat struct {
|
|
Status string
|
|
Count int
|
|
}
|
|
|
|
type Job struct {
|
|
Company string
|
|
Title string
|
|
Score float64
|
|
AppliedAt string
|
|
}
|
|
|
|
// Analyze computes all statistics via direct SQL GROUP BY queries.
|
|
func Analyze(db *sql.DB) (Stats, error) {
|
|
stats := Stats{}
|
|
|
|
// Total applications
|
|
var total int
|
|
err := db.QueryRow(`SELECT COUNT(*) FROM applications`).Scan(&total)
|
|
if err != nil {
|
|
return stats, fmt.Errorf("total count: %w", err)
|
|
}
|
|
stats.TotalApplications = total
|
|
|
|
// Average score
|
|
var avgScore sql.NullFloat64
|
|
err = db.QueryRow(`SELECT AVG(score) FROM applications WHERE score IS NOT NULL`).Scan(&avgScore)
|
|
if err != nil {
|
|
return stats, fmt.Errorf("avg score: %w", err)
|
|
}
|
|
if avgScore.Valid {
|
|
stats.AvgScore = avgScore.Float64
|
|
}
|
|
|
|
// Ghost rate: COUNT(Applied) / COUNT(Applied|Responded|Interview|Offer|Rejected)
|
|
if err := computeGhostRate(db, &stats); err != nil {
|
|
return stats, fmt.Errorf("ghost rate: %w", err)
|
|
}
|
|
|
|
// Score distribution
|
|
if err := computeScoreDistribution(db, &stats); err != nil {
|
|
return stats, fmt.Errorf("score distribution: %w", err)
|
|
}
|
|
|
|
// Top sources
|
|
if err := computeTopSources(db, &stats); err != nil {
|
|
return stats, fmt.Errorf("top sources: %w", err)
|
|
}
|
|
|
|
// Top archetypes
|
|
if err := computeTopArchetypes(db, &stats); err != nil {
|
|
return stats, fmt.Errorf("top archetypes: %w", err)
|
|
}
|
|
|
|
// Status breakdown
|
|
if err := computeStatusBreakdown(db, &stats); err != nil {
|
|
return stats, fmt.Errorf("status breakdown: %w", err)
|
|
}
|
|
|
|
// High-score ghosts
|
|
if err := computeHighScoreGhosts(db, &stats); err != nil {
|
|
return stats, fmt.Errorf("high score ghosts: %w", err)
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
func computeGhostRate(db *sql.DB, stats *Stats) error {
|
|
var applied, progressed int
|
|
err := db.QueryRow(`
|
|
SELECT
|
|
COUNT(CASE WHEN status = 'Applied' THEN 1 END),
|
|
COUNT(CASE WHEN status IN ('Applied','Responded','Interview','Offer','Rejected') THEN 1 END)
|
|
FROM applications
|
|
`).Scan(&applied, &progressed)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if progressed > 0 {
|
|
stats.GhostRate = float64(applied) / float64(progressed)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func computeScoreDistribution(db *sql.DB, stats *Stats) error {
|
|
rows, err := db.Query(`
|
|
SELECT
|
|
CASE
|
|
WHEN score >= 4.0 THEN '4.0-5.0'
|
|
WHEN score >= 3.0 THEN '3.0-4.0'
|
|
WHEN score >= 2.0 THEN '2.0-3.0'
|
|
ELSE '0.0-2.0'
|
|
END AS range,
|
|
COUNT(*) AS count
|
|
FROM applications
|
|
WHERE score IS NOT NULL
|
|
GROUP BY range
|
|
ORDER BY range DESC
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var r string
|
|
var count int
|
|
if err := rows.Scan(&r, &count); err != nil {
|
|
return err
|
|
}
|
|
stats.ScoreDistribution = append(stats.ScoreDistribution, Bucket{
|
|
Range: r,
|
|
Count: count,
|
|
})
|
|
}
|
|
return rows.Err()
|
|
}
|
|
|
|
func computeTopSources(db *sql.DB, stats *Stats) error {
|
|
rows, err := db.Query(`
|
|
SELECT
|
|
j.source,
|
|
COUNT(*) AS count,
|
|
AVG(a.score) AS avg_score
|
|
FROM applications a
|
|
JOIN jobs j ON a.job_id = j.id
|
|
WHERE j.source IS NOT NULL
|
|
GROUP BY j.source
|
|
ORDER BY count DESC
|
|
LIMIT 10
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var source string
|
|
var count int
|
|
var avgScore sql.NullFloat64
|
|
if err := rows.Scan(&source, &count, &avgScore); err != nil {
|
|
return err
|
|
}
|
|
stat := SourceStat{
|
|
Source: source,
|
|
Count: count,
|
|
}
|
|
if avgScore.Valid {
|
|
stat.AvgScore = avgScore.Float64
|
|
}
|
|
stats.TopSources = append(stats.TopSources, stat)
|
|
}
|
|
return rows.Err()
|
|
}
|
|
|
|
func computeTopArchetypes(db *sql.DB, stats *Stats) error {
|
|
rows, err := db.Query(`
|
|
SELECT
|
|
archetype,
|
|
COUNT(*) AS count,
|
|
AVG(score) AS avg_score
|
|
FROM applications
|
|
WHERE archetype IS NOT NULL
|
|
GROUP BY archetype
|
|
ORDER BY count DESC
|
|
LIMIT 10
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var archetype string
|
|
var count int
|
|
var avgScore sql.NullFloat64
|
|
if err := rows.Scan(&archetype, &count, &avgScore); err != nil {
|
|
return err
|
|
}
|
|
stat := ArchetypeStat{
|
|
Archetype: archetype,
|
|
Count: count,
|
|
}
|
|
if avgScore.Valid {
|
|
stat.AvgScore = avgScore.Float64
|
|
}
|
|
stats.TopArchetypes = append(stats.TopArchetypes, stat)
|
|
}
|
|
return rows.Err()
|
|
}
|
|
|
|
func computeStatusBreakdown(db *sql.DB, stats *Stats) error {
|
|
rows, err := db.Query(`
|
|
SELECT status, COUNT(*) AS count
|
|
FROM applications
|
|
GROUP BY status
|
|
ORDER BY count DESC
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
statusOrder := map[string]int{
|
|
"Applied": 1, "Responded": 2, "Interview": 3, "Offer": 4,
|
|
"Rejected": 5, "Discarded": 6, "SKIP": 7, "Evaluated": 8,
|
|
}
|
|
|
|
var tmp []StatusStat
|
|
for rows.Next() {
|
|
var status string
|
|
var count int
|
|
if err := rows.Scan(&status, &count); err != nil {
|
|
return err
|
|
}
|
|
tmp = append(tmp, StatusStat{Status: status, Count: count})
|
|
}
|
|
|
|
sort.Slice(tmp, func(i, j int) bool {
|
|
iord, iok := statusOrder[tmp[i].Status]
|
|
jord, jok := statusOrder[tmp[j].Status]
|
|
if !iok || !jok {
|
|
return tmp[i].Count > tmp[j].Count
|
|
}
|
|
return iord < jord
|
|
})
|
|
stats.StatusBreakdown = tmp
|
|
|
|
return rows.Err()
|
|
}
|
|
|
|
func computeHighScoreGhosts(db *sql.DB, stats *Stats) error {
|
|
rows, err := db.Query(`
|
|
SELECT j.company, j.title, a.score, a.applied_at
|
|
FROM applications a
|
|
JOIN jobs j ON a.job_id = j.id
|
|
WHERE a.score >= 4.0 AND a.status = 'Applied'
|
|
ORDER BY a.score DESC, a.applied_at DESC
|
|
LIMIT 20
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var company, title string
|
|
var score float64
|
|
var appliedAt sql.NullTime
|
|
if err := rows.Scan(&company, &title, &score, &appliedAt); err != nil {
|
|
return err
|
|
}
|
|
job := Job{
|
|
Company: company,
|
|
Title: title,
|
|
Score: score,
|
|
}
|
|
if appliedAt.Valid {
|
|
job.AppliedAt = appliedAt.Time.Format("2006-01-02")
|
|
}
|
|
stats.HighScoreGhosts = append(stats.HighScoreGhosts, job)
|
|
}
|
|
return rows.Err()
|
|
}
|
|
|
|
// Format returns a plain-text summary for CLI output.
|
|
func Format(s Stats) string {
|
|
var b strings.Builder
|
|
|
|
fmt.Fprintf(&b, "Applications: %d\n", s.TotalApplications)
|
|
|
|
ghostPct := s.GhostRate * 100
|
|
fmt.Fprintf(&b, "Ghost Rate: %.0f%% (applied, never heard back)\n", ghostPct)
|
|
|
|
fmt.Fprintf(&b, "Average Score: %.1f/5\n", s.AvgScore)
|
|
|
|
if len(s.ScoreDistribution) > 0 {
|
|
b.WriteString("\nScore Distribution:\n")
|
|
for _, bucket := range s.ScoreDistribution {
|
|
pct := float64(bucket.Count) * 100 / float64(s.TotalApplications)
|
|
if s.TotalApplications == 0 {
|
|
pct = 0
|
|
}
|
|
fmt.Fprintf(&b, " %s: %d (%.0f%%)\n", bucket.Range, bucket.Count, pct)
|
|
}
|
|
}
|
|
|
|
if len(s.TopSources) > 0 {
|
|
b.WriteString("\nTop Sources:\n")
|
|
for _, src := range s.TopSources {
|
|
fmt.Fprintf(&b, " %s: %d jobs, avg %.1f\n", src.Source, src.Count, src.AvgScore)
|
|
}
|
|
}
|
|
|
|
if len(s.TopArchetypes) > 0 {
|
|
b.WriteString("\nTop Archetypes:\n")
|
|
for _, arch := range s.TopArchetypes {
|
|
fmt.Fprintf(&b, " %s: %d, avg %.1f\n", arch.Archetype, arch.Count, arch.AvgScore)
|
|
}
|
|
}
|
|
|
|
if len(s.HighScoreGhosts) > 0 {
|
|
b.WriteString("\nHigh-Score Ghosts (score ≥ 4.0, no response):\n")
|
|
for _, job := range s.HighScoreGhosts {
|
|
dateStr := ""
|
|
if job.AppliedAt != "" {
|
|
dateStr = " (" + job.AppliedAt + ")"
|
|
}
|
|
fmt.Fprintf(&b, " %s — %s (%.1f)%s\n", job.Company, job.Title, job.Score, dateStr)
|
|
}
|
|
}
|
|
|
|
return b.String()
|
|
}
|