167 lines
3.8 KiB
Go
167 lines
3.8 KiB
Go
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()
|
|
}
|