initial public release
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user