#!/usr/bin/env node /** * followup-cadence.mjs — Follow-up Cadence Tracker for career-ops * * Parses applications.md + follow-ups.md, calculates follow-up cadence * for active applications, extracts contacts, and flags overdue entries. * * Run: node followup-cadence.mjs (JSON to stdout) * node followup-cadence.mjs --summary (human-readable dashboard) * node followup-cadence.mjs --overdue-only * node followup-cadence.mjs --applied-days 10 */ import { readFileSync, existsSync } from 'fs'; import { join, dirname, relative, sep } from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; import yaml from 'js-yaml'; import { resolveColumns, parseTrackerRow } from './tracker-parse.mjs'; const CAREER_OPS = dirname(fileURLToPath(import.meta.url)); const APPS_FILE = existsSync(join(CAREER_OPS, 'data/applications.md')) ? join(CAREER_OPS, 'data/applications.md') : join(CAREER_OPS, 'applications.md'); const FOLLOWUPS_FILE = join(CAREER_OPS, 'data/follow-ups.md'); const PROFILE_FILE = process.env.CAREER_OPS_PROFILE || join(CAREER_OPS, 'config/profile.yml'); // --- CLI args --- const args = process.argv.slice(2); const summaryMode = args.includes('--summary'); const overdueOnly = args.includes('--overdue-only'); const appliedDaysIdx = args.indexOf('--applied-days'); const appliedDaysOverride = appliedDaysIdx !== -1 ? parseInt(args[appliedDaysIdx + 1], 10) : null; // --- Cadence config --- export const DEFAULT_CADENCE = { applied_first: 7, applied_subsequent: 7, applied_max_followups: 2, responded_initial: 1, responded_subsequent: 3, interview_thankyou: 1, }; const PROFILE_CADENCE_KEYS = { applied_first_days: 'applied_first', applied_subsequent_days: 'applied_subsequent', applied_max_followups: 'applied_max_followups', responded_initial_days: 'responded_initial', responded_subsequent_days: 'responded_subsequent', interview_thankyou_days: 'interview_thankyou', }; function positiveInteger(value) { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; } export function loadProfileCadence(profilePath = PROFILE_FILE) { if (!profilePath || !existsSync(profilePath)) return {}; let raw; try { raw = yaml.load(readFileSync(profilePath, 'utf-8')) || {}; } catch { return {}; } const source = raw.followup_cadence || {}; const cadence = {}; for (const [profileKey, cadenceKey] of Object.entries(PROFILE_CADENCE_KEYS)) { const parsed = positiveInteger(source[profileKey]); if (parsed !== null) cadence[cadenceKey] = parsed; } return cadence; } export function resolveCadenceConfig({ profilePath = PROFILE_FILE, appliedDays = appliedDaysOverride } = {}) { const cadence = { ...DEFAULT_CADENCE, ...loadProfileCadence(profilePath) }; const cliApplied = positiveInteger(appliedDays); if (cliApplied !== null) cadence.applied_first = cliApplied; return cadence; } const CADENCE = resolveCadenceConfig(); // --- Status normalization (mirrors verify-pipeline.mjs) --- const ALIASES = { 'evaluada': 'evaluated', 'condicional': 'evaluated', 'hold': 'evaluated', 'evaluar': 'evaluated', 'verificar': 'evaluated', 'aplicado': 'applied', 'enviada': 'applied', 'aplicada': 'applied', 'applied': 'applied', 'sent': 'applied', 'respondido': 'responded', 'entrevista': 'interview', 'oferta': 'offer', 'rechazado': 'rejected', 'rechazada': 'rejected', 'descartado': 'discarded', 'descartada': 'discarded', 'cerrada': 'discarded', 'cancelada': 'discarded', 'no aplicar': 'skip', 'no_aplicar': 'skip', 'monitor': 'skip', 'geo blocker': 'skip', }; const ACTIONABLE_STATUSES = ['applied', 'responded', 'interview']; export function normalizeStatus(raw) { const clean = raw.replace(/\*\*/g, '').trim().toLowerCase() .replace(/\s+\d{4}-\d{2}-\d{2}.*$/, '').trim(); return ALIASES[clean] || clean; } // --- Date helpers --- function today() { return new Date(new Date().toISOString().split('T')[0]); } export function parseDate(dateStr) { if (!dateStr || !/^\d{4}-\d{2}-\d{2}$/.test(dateStr.trim())) return null; return new Date(dateStr.trim()); } // The tracker `date` column is often the evaluation date, while the real // submission date is recorded in the notes as "Applied YYYY-MM-DD" (or // "APPLIED ..."). Prefer that so cadence reflects when the application actually // went out, not when the role was evaluated. Returns the first such date, or // null when the notes don't carry one (caller falls back to the date column). export function parseAppliedDate(notes) { if (!notes) return null; const m = String(notes).match(/\bapplied\s+(\d{4}-\d{2}-\d{2})/i); return m ? m[1] : null; } export function daysBetween(d1, d2) { return Math.floor((d2 - d1) / (1000 * 60 * 60 * 24)); } export function addDays(date, days) { const result = new Date(date); result.setUTCDate(result.getUTCDate() + days); return result.toISOString().split('T')[0]; } // --- Parse applications.md --- function parseTracker() { if (!existsSync(APPS_FILE)) return []; const content = readFileSync(APPS_FILE, 'utf-8'); const lines = content.split('\n'); const colmap = resolveColumns(lines); const entries = []; for (const line of lines) { const row = parseTrackerRow(line, colmap); if (row) entries.push(row); } return entries; } // --- Parse follow-ups.md --- function parseFollowups() { if (!existsSync(FOLLOWUPS_FILE)) return []; const content = readFileSync(FOLLOWUPS_FILE, 'utf-8'); const entries = []; for (const line of content.split('\n')) { if (!line.startsWith('|')) continue; const parts = line.split('|').map(s => s.trim()); if (parts.length < 8) continue; const num = parseInt(parts[1]); if (isNaN(num)) continue; entries.push({ num, appNum: parseInt(parts[2]), date: parts[3], company: parts[4], role: parts[5], channel: parts[6], contact: parts[7], notes: parts[8] || '', }); } return entries; } // --- Next-date overrides (pins) --- // A user can PIN an application's next follow-up date, taking precedence over // the computed cadence (a pin even revives a cold application) until a // follow-up logged on/after the pin's set-date resumes the normal schedule. // Stored in data/follow-ups.md as directive lines: // - next #42 2026-07-10 (set 2026-07-02) // The `(set …)` part records when the pin was made; if omitted (hand-written) // it defaults to the pinned date itself. The LAST pin line per application wins. const OVERRIDE_RE = /^-\s+next\s+#(\d+)\s+(\d{4}-\d{2}-\d{2})(?:\s+\(set\s+(\d{4}-\d{2}-\d{2})\))?\s*$/i; export function parseNextOverrides(content) { const byApp = new Map(); for (const line of content.split('\n')) { const m = line.match(OVERRIDE_RE); if (!m) continue; const date = m[2]; if (!parseDate(date)) continue; // an impossible pinned date never poisons the analysis const appNum = parseInt(m[1]); byApp.set(appNum, { appNum, date, setDate: m[3] || date }); } return byApp; } // The pin applies until a follow-up is logged AFTER it. Ties favor the pin: // "log a follow-up, then pin the next date" is the common same-day flow. export function resolveNextOverride(override, lastFollowupDate) { if (!override) return null; if (lastFollowupDate && lastFollowupDate > override.setDate) return null; return override.date; } function parseOverrides() { if (!existsSync(FOLLOWUPS_FILE)) return new Map(); return parseNextOverrides(readFileSync(FOLLOWUPS_FILE, 'utf-8')); } // --- Extract contacts from notes --- function extractContacts(notes) { if (!notes) return []; const contacts = []; const emailRegex = /[\w.-]+@[\w.-]+\.\w+/g; const emails = notes.match(emailRegex) || []; for (const email of emails) { // Try to extract name before email: "Emailed Name at" or "contact: Name" let name = null; const beforeEmail = notes.substring(0, notes.indexOf(email)); const nameMatch = beforeEmail.match(/(?:Emailed|emailed|contact[:\s]+|to\s+)([A-Z][a-z]+ ?[A-Z]?[a-z]*)\s*(?:at|@|$)/i); if (nameMatch) name = nameMatch[1].trim(); contacts.push({ email, name }); } return contacts; } // --- Resolve report path --- export function resolveReportPath(reportField, appsFile = APPS_FILE, repoRoot = CAREER_OPS) { const match = reportField.match(/\]\(([^)]+)\)/); if (!match) return null; // Report links in the tracker are normalized relative to the tracker file's // own directory (see PR #760 — `merge-tracker.mjs --migrate`). Resolve against // dirname(APPS_FILE), not the project root, otherwise relative paths like // `../reports/...` (the data/applications.md layout) escape above the project. const fullPath = join(dirname(appsFile), match[1]); const repoRelative = relative(repoRoot, fullPath).split(sep).join('/'); if (repoRelative.startsWith('../') || repoRelative === '..' || !repoRelative.startsWith('reports/')) return null; return existsSync(fullPath) ? repoRelative : null; } // --- Compute urgency --- export function computeUrgency(status, daysSinceApp, daysSinceLastFollowup, followupCount) { if (status === 'applied') { if (followupCount >= CADENCE.applied_max_followups) return 'cold'; if (followupCount === 0 && daysSinceApp >= CADENCE.applied_first) return 'overdue'; if (followupCount > 0 && daysSinceLastFollowup !== null && daysSinceLastFollowup >= CADENCE.applied_subsequent) return 'overdue'; return 'waiting'; } if (status === 'responded') { if (daysSinceApp < CADENCE.responded_initial) return 'urgent'; if (daysSinceApp >= CADENCE.responded_subsequent) return 'overdue'; return 'waiting'; } if (status === 'interview') { if (daysSinceApp >= CADENCE.interview_thankyou) return 'overdue'; return 'waiting'; } return 'waiting'; } // --- Compute next follow-up date --- export function computeNextFollowupDate(status, appDate, lastFollowupDate, followupCount) { if (status === 'applied') { if (followupCount >= CADENCE.applied_max_followups) return null; // cold if (followupCount === 0) return addDays(parseDate(appDate), CADENCE.applied_first); if (lastFollowupDate) return addDays(parseDate(lastFollowupDate), CADENCE.applied_subsequent); return addDays(parseDate(appDate), CADENCE.applied_first); } if (status === 'responded') { if (lastFollowupDate) return addDays(parseDate(lastFollowupDate), CADENCE.responded_subsequent); return addDays(parseDate(appDate), CADENCE.responded_initial); } if (status === 'interview') { return addDays(parseDate(appDate), CADENCE.interview_thankyou); } return null; } // --- Main analysis --- function analyze() { const apps = parseTracker(); if (apps.length === 0) { return { error: 'No applications found in tracker.' }; } const followups = parseFollowups(); const overrides = parseOverrides(); // Group follow-ups by app number const followupsByApp = new Map(); for (const fu of followups) { if (!followupsByApp.has(fu.appNum)) followupsByApp.set(fu.appNum, []); followupsByApp.get(fu.appNum).push(fu); } const now = today(); const entries = []; for (const app of apps) { const normalized = normalizeStatus(app.status); if (!ACTIONABLE_STATUSES.includes(normalized)) continue; // Prefer the "Applied YYYY-MM-DD" date from notes; fall back to the column. const appliedDate = parseAppliedDate(app.notes) || app.date; const appDate = parseDate(appliedDate); if (!appDate) continue; const daysSinceApp = daysBetween(appDate, now); const appFollowups = followupsByApp.get(app.num) || []; const followupCount = appFollowups.length; // Find most recent follow-up let lastFollowupDate = null; let daysSinceLastFollowup = null; if (appFollowups.length > 0) { const sorted = appFollowups.sort((a, b) => (a.date > b.date ? -1 : 1)); lastFollowupDate = sorted[0].date; const lastDate = parseDate(lastFollowupDate); if (lastDate) daysSinceLastFollowup = daysBetween(lastDate, now); } let urgency = computeUrgency(normalized, daysSinceApp, daysSinceLastFollowup, followupCount); let nextFollowupDate = computeNextFollowupDate(normalized, appliedDate, lastFollowupDate, followupCount); // A pinned next-date takes precedence over the computed cadence (explicit // user intent — it even revives a cold application) until a follow-up // logged after the pin resumes the normal schedule. const nextOverride = resolveNextOverride(overrides.get(app.num), lastFollowupDate); if (nextOverride) { nextFollowupDate = nextOverride; urgency = daysBetween(parseDate(nextOverride), now) >= 0 ? 'overdue' : 'waiting'; } const nextDate = nextFollowupDate ? parseDate(nextFollowupDate) : null; const daysUntilNext = nextDate ? daysBetween(now, nextDate) : null; const contacts = extractContacts(app.notes); const reportPath = resolveReportPath(app.report); entries.push({ num: app.num, date: app.date, appliedDate, company: app.company, role: app.role, status: normalized, score: app.score, notes: app.notes, reportPath, contacts, daysSinceApplication: daysSinceApp, daysSinceLastFollowup, followupCount, urgency, nextFollowupDate, nextOverride, daysUntilNext, }); } // Sort by urgency priority: urgent > overdue > waiting > cold const urgencyOrder = { urgent: 0, overdue: 1, waiting: 2, cold: 3 }; entries.sort((a, b) => (urgencyOrder[a.urgency] ?? 9) - (urgencyOrder[b.urgency] ?? 9)); const filtered = overdueOnly ? entries.filter(e => e.urgency === 'overdue' || e.urgency === 'urgent') : entries; return { metadata: { analysisDate: now.toISOString().split('T')[0], totalTracked: apps.length, actionable: entries.length, overdue: entries.filter(e => e.urgency === 'overdue').length, urgent: entries.filter(e => e.urgency === 'urgent').length, cold: entries.filter(e => e.urgency === 'cold').length, waiting: entries.filter(e => e.urgency === 'waiting').length, }, entries: filtered, cadenceConfig: CADENCE, }; } // --- Summary mode --- function printSummary(result) { if (result.error) { console.log(`\n${result.error}\n`); return; } const { metadata, entries } = result; console.log(`\n${'='.repeat(70)}`); console.log(` Follow-up Cadence Dashboard — ${metadata.analysisDate}`); console.log(` ${metadata.totalTracked} total applications, ${metadata.actionable} actionable`); console.log(`${'='.repeat(70)}\n`); if (entries.length === 0) { console.log(' No active applications to track. Apply to some roles first.\n'); return; } // Status summary const urgencyIcon = { urgent: 'URGENT', overdue: 'OVERDUE', waiting: 'waiting', cold: 'COLD' }; console.log(` ${metadata.urgent} urgent | ${metadata.overdue} overdue | ${metadata.waiting} waiting | ${metadata.cold} cold\n`); // Table header console.log(' ' + '#'.padEnd(5) + 'Company'.padEnd(16) + 'Status'.padEnd(12) + 'Days'.padEnd(6) + 'F/U'.padEnd(5) + 'Next'.padEnd(13) + 'Urgency'.padEnd(10) + 'Contact'); console.log(' ' + '-'.repeat(80)); for (const e of entries) { const urgLabel = urgencyIcon[e.urgency] || e.urgency; const nextStr = e.nextFollowupDate || '-'; const contactStr = e.contacts.length > 0 ? e.contacts[0].email : '-'; console.log( ' ' + String(e.num).padEnd(5) + e.company.substring(0, 15).padEnd(16) + e.status.padEnd(12) + String(e.daysSinceApplication).padEnd(6) + String(e.followupCount).padEnd(5) + nextStr.padEnd(13) + urgLabel.padEnd(10) + contactStr ); } console.log(''); } // --- Run (CLI only; guarded so the module is safely importable for tests) --- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const result = analyze(); if (summaryMode) { printSummary(result); } else { console.log(JSON.stringify(result, null, 2)); } if (result.error) process.exit(1); }