initial public release
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
package followup
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var Cadence = map[string]time.Duration{
|
||||
"Applied": 7 * 24 * time.Hour,
|
||||
"Responded": 14 * 24 * time.Hour,
|
||||
"Interview": 3 * 24 * time.Hour,
|
||||
}
|
||||
|
||||
type PendingFollowUp struct {
|
||||
ApplicationID int64
|
||||
Company string
|
||||
Title string
|
||||
Status string
|
||||
StatusSince time.Time
|
||||
DueAt time.Time
|
||||
IsOverdue bool
|
||||
}
|
||||
|
||||
// Pending returns applications that need follow-up based on cadence.
|
||||
// Excludes apps with terminal statuses or those already contacted.
|
||||
func Pending(db *sql.DB) ([]PendingFollowUp, error) {
|
||||
now := time.Now()
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT
|
||||
a.id,
|
||||
j.company,
|
||||
j.title,
|
||||
a.status,
|
||||
a.updated_at,
|
||||
fu.contacted_at
|
||||
FROM applications a
|
||||
JOIN jobs j ON a.job_id = j.id
|
||||
LEFT JOIN follow_ups fu ON a.id = fu.application_id
|
||||
WHERE a.status IN ('Applied', 'Responded', 'Interview')
|
||||
AND fu.contacted_at IS NULL
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []PendingFollowUp
|
||||
for rows.Next() {
|
||||
var appID int64
|
||||
var company, title, status string
|
||||
var updatedAt time.Time
|
||||
var contacted sql.NullTime
|
||||
|
||||
if err := rows.Scan(&appID, &company, &title, &status, &updatedAt, &contacted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Skip if already contacted
|
||||
if contacted.Valid {
|
||||
continue
|
||||
}
|
||||
|
||||
cadence, ok := Cadence[status]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
dueAt := updatedAt.Add(cadence)
|
||||
isOverdue := now.After(dueAt)
|
||||
|
||||
items = append(items, PendingFollowUp{
|
||||
ApplicationID: appID,
|
||||
Company: company,
|
||||
Title: title,
|
||||
Status: status,
|
||||
StatusSince: updatedAt,
|
||||
DueAt: dueAt,
|
||||
IsOverdue: isOverdue,
|
||||
})
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sort: overdue first, then by due date
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].IsOverdue != items[j].IsOverdue {
|
||||
return items[i].IsOverdue
|
||||
}
|
||||
return items[i].DueAt.Before(items[j].DueAt)
|
||||
})
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// MarkContacted records that a follow-up was sent for an application.
|
||||
func MarkContacted(db *sql.DB, applicationID int64, notes string) error {
|
||||
var exists int
|
||||
if err := db.QueryRow(`SELECT 1 FROM applications WHERE id = ?`, applicationID).Scan(&exists); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("application %d not found", applicationID)
|
||||
}
|
||||
return fmt.Errorf("check application: %w", err)
|
||||
}
|
||||
res, err := db.Exec(`
|
||||
UPDATE follow_ups
|
||||
SET contacted_at = CURRENT_TIMESTAMP, notes = ?
|
||||
WHERE application_id = ?
|
||||
`, notes, applicationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update follow_ups: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return fmt.Errorf("no follow_up row for application %d", applicationID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Format returns a plain-text summary of pending follow-ups for CLI output.
|
||||
func Format(items []PendingFollowUp) string {
|
||||
if len(items) == 0 {
|
||||
return "No pending follow-ups.\n"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Pending Follow-ups (%d):\n\n", len(items))
|
||||
|
||||
var overdue []PendingFollowUp
|
||||
var dueSoon []PendingFollowUp
|
||||
|
||||
for _, item := range items {
|
||||
if item.IsOverdue {
|
||||
overdue = append(overdue, item)
|
||||
} else {
|
||||
dueSoon = append(dueSoon, item)
|
||||
}
|
||||
}
|
||||
|
||||
if len(overdue) > 0 {
|
||||
b.WriteString("OVERDUE:\n")
|
||||
for _, item := range overdue {
|
||||
statusDate := item.StatusSince.Format("2006-01-02")
|
||||
dueDate := item.DueAt.Format("2006-01-02")
|
||||
fmt.Fprintf(&b, " %s — %s (%s %s, was due %s)\n",
|
||||
item.Company, item.Title, item.Status, statusDate, dueDate)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(dueSoon) > 0 {
|
||||
b.WriteString("DUE SOON:\n")
|
||||
for _, item := range dueSoon {
|
||||
statusDate := item.StatusSince.Format("2006-01-02")
|
||||
dueDate := item.DueAt.Format("2006-01-02")
|
||||
fmt.Fprintf(&b, " %s — %s (%s %s, due %s)\n",
|
||||
item.Company, item.Title, item.Status, statusDate, dueDate)
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
Reference in New Issue
Block a user