# How `followup-seed.mjs` Pins the First Follow-Up Date When a Row Becomes Applied in `career-ops`

> Learn how followup-seed.mjs automatically pins the first follow-up date in santifer/career-ops when a row transitions to Applied, saving the date and pin creation time.

- Repository: [Santiago Fernández de Valderrama/career-ops](https://github.com/santifer/career-ops)
- Tags: internals
- Published: 2026-08-20

---

**`followup-seed.mjs` automatically creates a *pin directive* in [`data/follow-ups.md`](https://github.com/santifer/career-ops/blob/main/data/follow-ups.md) the moment a tracker entry's status changes to Applied, recording the next follow-up date and the date the pin was written.**

When a job application in `career-ops` transitions to **Applied**, the follow-up system needs to know when to remind you next. Rather than requiring manual entry, `followup-seed.mjs` handles this automatically by generating a *pin*—a specially formatted line that the cadence engine later reads to determine reminder timing. This article explains the exact mechanism, from path resolution to atomic file writing, as implemented in the `santifer/career-ops` repository.

## Locating Tracker and Follow-Up Files

The script begins by resolving two critical paths. `resolveTrackerPath` and `resolveFollowupsPath` check for environment overrides first, then fall back to sensible defaults ([`data/applications.md`](https://github.com/santifer/career-ops/blob/main/data/applications.md) and [`data/follow-ups.md`](https://github.com/santifer/career-ops/blob/main/data/follow-ups.md) respectively).

Source: [`resolveTrackerPath`](https://github.com/santifer/career-ops/blob/main/followup-seed.mjs#L198-L202), [`resolveFollowupsPath`](https://github.com/santifer/career-ops/blob/main/followup-seed.mjs#L204-L208).

## Reading and Validating the Target Row

Once paths are established, `readTrackerRows` parses the entire tracker file into structured objects. The script selects the row matching the provided `appNum` and immediately validates its status.

Source: [`readTrackerRows`](https://github.com/santifer/career-ops/blob/main/followup-seed.mjs#L220-L229).

### Status Validation

The `normalizeStatus` function standardizes status strings for comparison. Unless `--force` is passed, the script aborts if the row isn't **Applied**—preventing accidental pins for Wishlist or Interviewing entries.

Source: [`normalizeStatus` check](https://github.com/santifer/career-ops/blob/main/followup-seed.mjs#L47-L51).

## Determining the Applied Date

The **applied date** anchors all follow-up calculations. `resolveAppliedDate` implements a three-tier fallback:

1. **Explicit `--date` flag** — highest priority, user-supplied value
2. **"Applied YYYY-MM-DD" note** — parsed from the row's notes column
3. **Tracker `date` column** — the original entry date
4. **Today** — final fallback via `localToday`

Source: [`resolveAppliedDate`](https://github.com/santifer/career-ops/blob/main/followup-seed.mjs#L158-L180).

## Loading Cadence Configuration and Computing Next Date

The `resolveCadenceConfig` function (imported from `followup-cadence.mjs`) reads [`config/profile.yml`](https://github.com/santifer/career-ops/blob/main/config/profile.yml) and extracts the `applied_first` offset—the number of days after application before the first follow-up.

The calculation is straightforward:

```js
const nextDate = addDays(parseDate(appliedDate), cadence.applied_first);

```

Source: [`addDays` usage](https://github.com/santifer/career-ops/blob/main/followup-seed.mjs#L57-L58), [`resolveCadenceConfig`](https://github.com/santifer/career-ops/blob/main/followup-cadence.mjs#L72-L78).

## Creating the Pin Line

The `formatPinLine` helper renders a machine-readable directive that the cadence parser recognizes:

```js
- next #<appNum> <nextDate> (set <setDate>)

```

This format encodes: the follow-up type (`next`), application number, scheduled follow-up date, and when the pin was created.

Source: [`formatPinLine`](https://github.com/santifer/career-ops/blob/main/followup-seed.mjs#L186-L192).

## Idempotency and Concurrency Protection

### Preventing Duplicate Pins

Before writing, `isAlreadySeeded` checks [`follow-ups.md`](https://github.com/santifer/career-ops/blob/main/follow-ups.md) for existing pins or table rows matching `appNum`. If found and `--force` isn't set, the script exits with `seeded: false`, avoiding duplicate entries.

Source: [`isAlreadySeeded`](https://github.com/santifer/career-ops/blob/main/followup-seed.mjs#L48-L52).

### Cross-Process Locking

`withFollowupsLock` (using `acquireFollowupsLock` from `pipeline-lock.mjs`) ensures only one process can modify [`follow-ups.md`](https://github.com/santifer/career-ops/blob/main/follow-ups.md) at a time. This prevents race conditions when multiple CLI invocations or automation scripts run concurrently.

Source: [`withFollowupsLock`](https://github.com/santifer/career-ops/blob/main/followup-seed.mjs#L112-L125).

## Atomic File Writing

The pin is appended using `writeFileAtomic`, which:

- Creates a temporary file adjacent to the target
- Writes the complete updated content (including `FOLLOWUPS_HEADER` for new files)
- Renames the temp file over the original

This guarantees an all-or-nothing update—even if the process crashes, [`follow-ups.md`](https://github.com/santifer/career-ops/blob/main/follow-ups.md) never contains partial data.

Source: [`writeFileAtomic`](https://github.com/santifer/career-ops/blob/main/followup-seed.mjs#L30-L38), [`appendPins`](https://github.com/santifer/career-ops/blob/main/followup-seed.mjs#L41-L47).

## Usage Examples

### Programmatic API

```javascript
import { seedFollowup } from './followup-seed.mjs';

(async () => {
  const result = await seedFollowup(42, {
    // date: '2024-09-01',  // optional explicit date
    force: false,           // abort if not Applied
    dryRun: false,          // actually write the pin
  });

  console.log(result);
  // {
  //   seeded: true,
  //   appNum: 42,
  //   pin: '- next #42 2024-09-15 (set 2024-09-02)',
  //   nextDate: '2024-09-15',
  //   appliedDate: '2024-09-01',
  //   appDateSource: 'explicit',
  //   setDate: '2024-09-02'
  // }
})();

```

### CLI Invocation

```bash

# Pin with explicit apply date

node followup-seed.mjs 42 --date 2024-09-01

# Output:

# ✅ Seeded #42: next follow-up 2024-09-15 (applied 2024-09-01, set 2024-09-02)

```

### Bulk Backfill

```javascript
import { seedBackfill } from './followup-seed.mjs';

(async () => {
  const { seeded, skipped } = await seedBackfill({ dryRun: true });
  console.log(`Would seed ${seeded.length}, skip ${skipped.length} already pinned`);
})();

```

## Key Files in the Follow-Up System

| File | Purpose |
|------|---------|
| `followup-seed.mjs` | Core script for creating pin directives when rows become Applied |
| `followup-cadence.mjs` | Cadence configuration, date arithmetic (`addDays`), and scheduling logic |
| `tracker-parse.mjs` | Parses [`data/applications.md`](https://github.com/santifer/career-ops/blob/main/data/applications.md) into structured row objects |
| `lib/local-today.mjs` | Provides current date for `set` timestamps |
| `pipeline-lock.mjs` | Filesystem locking primitives for safe concurrent access |

## Summary

- **`followup-seed.mjs`** automates first follow-up creation by generating pins in [`data/follow-ups.md`](https://github.com/santifer/career-ops/blob/main/data/follow-ups.md) when tracker rows reach **Applied** status.
- **Date resolution** follows a strict priority: explicit flag → "Applied YYYY-MM-DD" note → tracker `date` column → today.
- **Cadence configuration** from [`config/profile.yml`](https://github.com/santifer/career-ops/blob/main/config/profile.yml) determines the `applied_first` offset added to the resolved date.
- **Idempotency** via `isAlreadySeeded` prevents duplicate pins without `--force`.
- **Concurrency safety** through `withFollowupsLock` and atomic writes protects file integrity under parallel execution.

## Frequently Asked Questions

### What happens if I run `followup-seed.mjs` on a non-Applied row?

Without `--force`, the script aborts after `normalizeStatus` detects the mismatch. With `--force`, it proceeds regardless of status—useful for edge cases or testing.

### Can I override the automatically detected applied date?

Yes. Pass `--date YYYY-MM-DD` to set it explicitly. Otherwise, the script searches your notes for "Applied YYYY-MM-DD", falls back to the tracker's `date` column, and finally uses today's date.

### How does the script prevent duplicate pins?

`isAlreadySeeded` scans [`follow-ups.md`](https://github.com/santifer/career-ops/blob/main/follow-ups.md) for existing pins matching the `appNum`. It also checks for table rows (legacy format). If either exists, the script exits with `seeded: false` unless you pass `--force`.

### What file locking mechanism does `career-ops` use?

`withFollowupsLock` leverages `acquireFollowupsLock` from `pipeline-lock.mjs`, which implements robust filesystem-based locking. This ensures only one process modifies [`follow-ups.md`](https://github.com/santifer/career-ops/blob/main/follow-ups.md) at a time, preventing corruption during concurrent writes.