#!/usr/bin/env node /** * closing-soon.mjs — surface tracker rows whose posting is about to close * * A posting's closing date has no structured home in the tracker, so people who * evaluate first and apply later type it into the free-text Notes cell * ("closes 2026-10-22", "deadline Nov 4"). This script reads those notes (and, * when a row's Notes carry no closing marker at all, the archived `jds/` * capture that `jd-capture.mjs` resolves by report number) and lists the rows * that close within N days, soonest first. Rows whose date has already passed * go in a separate `overdue` group; rows where a closing marker was found but * no date could be read go in `unparsed`, so nothing is silently dropped. * * Zero LLM. The report only reads. It writes nothing, and no tracker * column is added (issue #5412). * * Recognized dates, each introduced by `closes`, `closing date`, `closing`, * `deadline` or `apply by` (optionally followed by `:`, `on`, `by`, `is` and a * weekday name): * - ISO 2026-10-22 * - Mon D, YYYY Oct 22, 2026 (month names or 3-letter abbreviations) * - Mon D Oct 22 (this year, or next year when the date * passed more than ROLLOVER_GRACE_DAYS ago) * - MM/DD/YYYY 10/22/2026 (only when unambiguous: the second part * is above 12, or both parts are equal) * Anything else after a marker (04/05/2026, 4 Nov 2026, "TBD") is reported as * `unparsed` rather than guessed. * * Run: node closing-soon.mjs (JSON to stdout) * node closing-soon.mjs --summary (human-readable table) * node closing-soon.mjs --within 14 (window in days, default 7) * node closing-soon.mjs --status Evaluated,Applied * node closing-soon.mjs --today 2026-10-17 (deterministic runs/tests) * node closing-soon.mjs --tracker path/to/applications.md --jds path/to/jds * node closing-soon.mjs --clashes (interview days with closings piling up) * node closing-soon.mjs --clashes --clash-min 3 --interviews path/to/active-interviews.md * * --clashes is opt-in: it reads data/active-interviews.md (leading YYYY-MM-DD of * the Date/Time cell; rows in a terminal Status are skipped) and, for each * interview date inside the window, counts the closing-soon rows that close on * that day or the day before. At --clash-min (default 2) or more it adds a * `clashes` group. Without the flag the output is unchanged. */ import { readFileSync, existsSync } from 'fs'; import { join, dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; import { loadYaml } from './lib/yaml-document.mjs'; import { resolveColumns, parseTrackerRow, linkedReportNumbers } from './tracker-parse.mjs'; import { findCaptureForReport, captureSlug } from './jd-capture.mjs'; import { flagValue, hasFlag, validateFlags } from './lib/cli-flags.mjs'; import { localToday } from './lib/local-today.mjs'; import { parseDate } from './lib/parse-date.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; import { getCareerOpsRoot, resolveTrackerPath } from './path-resolver.mjs'; import { parseActiveInterviews, findColumn } from './process-quality.mjs'; const CODE_ROOT = dirname(fileURLToPath(import.meta.url)); const STATES_FILE = join(CODE_ROOT, 'templates/states.yml'); export const DEFAULT_WITHIN_DAYS = 7; export const DEFAULT_STATUS = 'Evaluated'; export const DEFAULT_CLASH_MIN = 2; /** Interview Status values that no longer hold a date (emoji prefixes tolerated). */ const TERMINAL_INTERVIEW_RE = /\b(?:rejected|declined|ghosted|done)\b/i; /** * A `Mon D` date (no year) that already passed by more than this many days is * read as next year's occurrence ("closes Jan 5" typed in December). Inside the * grace it stays this year and shows up as overdue. */ export const ROLLOVER_GRACE_DAYS = 60; /** Largest capture read into memory; a JD longer than this is not a note. */ const MAX_CAPTURE_BYTES = 256 * 1024; const READABLE_CAPTURE_EXTS = new Set(['.md', '.txt', '.html', '.htm']); const MS_PER_DAY = 86_400_000; const MONTHS = { jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, apr: 4, april: 4, may: 5, jun: 6, june: 6, jul: 7, july: 7, aug: 8, august: 8, sep: 9, sept: 9, september: 9, oct: 10, october: 10, nov: 11, november: 11, dec: 12, december: 12, }; const MONTH_RE = '(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sept?(?:ember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)'; // `closing date` is listed before `closing` so the longer form wins at the same // position; `closed` is deliberately not a marker (it states a past fact, with // no date a reader could act on). const MARKER_RE = /\b(closes|closing\s+date|closing|deadline|apply\s+by)\b/gi; const LEAD_IN_RE = new RegExp( '^\\s*[:=\\-\u2013\u2014]?\\s*(?:(?:on|by|is|at)\\s+)?' + '(?:(?:mon|tue|tues|wed|thu|thur|thurs|fri|sat|sun)[a-z]*\\.?,?\\s+)?', 'i', ); const ISO_RE = /^(\d{4})-(\d{2})-(\d{2})(?![\d-])/; const MON_D_Y_RE = new RegExp(`^(${MONTH_RE})\\.?\\s+(\\d{1,2})(?:st|nd|rd|th)?\\s*,?\\s*(\\d{4})(?!\\d)`, 'i'); const MON_D_RE = new RegExp(`^(${MONTH_RE})\\.?\\s+(\\d{1,2})(?:st|nd|rd|th)?(?![\\d/])(?!\\s*,?\\s*\\d{3,})`, 'i'); const NUMERIC_RE = /^(\d{1,2})\/(\d{1,2})\/(\d{4})(?!\d)/; const DATEISH_RE = new RegExp(`^(?:\\d|${MONTH_RE}\\b)`, 'i'); const pad = (n) => String(n).padStart(2, '0'); const isoDay = (d) => d.toISOString().slice(0, 10); const daysBetween = (from, to) => Math.round((to.getTime() - from.getTime()) / MS_PER_DAY); /** Calendar-valid UTC date for y/m/d, or null (2026-02-31, Feb 29 off-leap). */ function makeDate(y, m, d) { return parseDate(`${y}-${pad(m)}-${pad(d)}`); } /** * Read one date off the front of `tail` (the text just after a marker). * * @returns {{date: Date, form: string, length: number}|{unparsed: string}|null} * null when the tail does not look like a date at all. */ function readDate(tail, today) { let m = ISO_RE.exec(tail); if (m) { const date = makeDate(m[1], m[2], m[3]); return date ? { date, form: 'iso' } : { unparsed: 'invalid calendar date' }; } m = MON_D_Y_RE.exec(tail); if (m) { const date = makeDate(m[3], MONTHS[m[1].toLowerCase()], m[2]); return date ? { date, form: 'mon-d-yyyy' } : { unparsed: 'invalid calendar date' }; } m = NUMERIC_RE.exec(tail); if (m) { const a = Number(m[1]); const b = Number(m[2]); // MM/DD/YYYY is only unambiguous when it cannot also be DD/MM/YYYY. if (a < 1 || a > 12 || b < 1 || b > 31) return { unparsed: 'not a valid MM/DD/YYYY date' }; if (b <= 12 && a !== b) return { unparsed: 'ambiguous MM/DD vs DD/MM' }; const date = makeDate(m[3], a, b); return date ? { date, form: 'mm-dd-yyyy' } : { unparsed: 'invalid calendar date' }; } m = MON_D_RE.exec(tail); if (m) { const month = MONTHS[m[1].toLowerCase()]; const day = Number(m[2]); const year = today.getUTCFullYear(); let date = makeDate(year, month, day); if (!date) return { unparsed: 'invalid calendar date' }; if (daysBetween(date, today) > ROLLOVER_GRACE_DAYS) { date = makeDate(year + 1, month, day); if (!date) return { unparsed: 'invalid calendar date' }; } return { date, form: 'mon-d' }; } return DATEISH_RE.test(tail) ? { unparsed: 'unrecognized date format' } : null; } /** * Find closing-date markers in free text and read the date after each. * * @param {string} text * @param {Date} today - UTC-midnight "today"; only used to place `Mon D`. * @returns {{dates: {date: string, form: string, marker: string}[], * unparsed: {marker: string, text: string, reason: string}[]}} * `dates` are in text order. A bare `closing` with no date-shaped text after * it is not a marker ("closing the loop"); every other marker without a * readable date is returned in `unparsed`. */ export function extractClosingDates(text, today, { strictMarkers = true } = {}) { const dates = []; const unparsed = []; const source = String(text ?? '').replace(/\s+/g, ' '); MARKER_RE.lastIndex = 0; let match; while ((match = MARKER_RE.exec(source)) !== null) { const marker = match[1].toLowerCase().replace(/\s+/g, ' '); const after = source.slice(match.index + match[0].length, match.index + match[0].length + 60); const tail = after.replace(LEAD_IN_RE, ''); const read = readDate(tail, today); if (read && read.date) { dates.push({ date: isoDay(read.date), form: read.form, marker }); } else if (read && read.unparsed) { unparsed.push({ marker, text: `${match[0]}${after}`.slice(0, 48).trim(), reason: read.unparsed }); } else if (strictMarkers && marker !== 'closing') { unparsed.push({ marker, text: `${match[0]}${after}`.slice(0, 48).trim(), reason: 'no date after marker' }); } } return { dates, unparsed }; } /** Canonical states from templates/states.yml, as lookups to the label. */ export function loadStateLookup(statesFile = STATES_FILE) { const doc = loadYaml(readFileSync(statesFile, 'utf-8')) || {}; const lookup = new Map(); for (const s of doc.states || []) { const label = String(s.label); for (const key of [s.id, s.label, ...(s.aliases || [])]) { lookup.set(String(key).trim().toLowerCase(), label); } } return lookup; } /** `Evaluated, applied` -> Set{'Evaluated','Applied'}; throws on an unknown state. */ export function resolveStatusFilter(value, lookup = loadStateLookup()) { const labels = new Set(); for (const part of String(value).split(',')) { const key = part.trim().toLowerCase(); if (!key) continue; const label = lookup.get(key); if (!label) { throw new Error(`Unknown status "${part.trim()}". Canonical states: ${[...new Set(lookup.values())].join(', ')}.`); } labels.add(label); } if (labels.size === 0) throw new Error('--status needs at least one state.'); return labels; } /** A tag longer than this is treated as plain text, which bounds the scan. */ const MAX_TAG_CHARS = 4096; /** * Index just past the '>' that ends the tag opening at html[start], skipping * quoted attribute values, or -1 when it does not close within MAX_TAG_CHARS. */ function tagEnd(html, start) { const limit = Math.min(html.length, start + MAX_TAG_CHARS); let quote = ''; for (let i = start + 1; i < limit; i++) { const c = html[i]; if (quote) { if (c === quote) quote = ''; } else if (c === '"' || c === "'") { quote = c; } else if (c === '>') { return i + 1; } } return -1; } /** * Flatten HTML to plain text for date matching only (never rendered): comments * and script/style blocks are dropped, tags (including quoted attribute values) * become spaces, and the few entities that matter are decoded. A single linear * pass, so hostile markup cannot trigger regex backtracking; best-effort, not * an HTML parser. */ export function htmlToText(html) { const lower = html.toLowerCase(); const missing = new Set(); // closers already searched for and absent: never search again const indexOfOnce = (needle, from) => { if (missing.has(needle)) return -1; const at = lower.indexOf(needle, from); if (at === -1) missing.add(needle); return at; }; // Closer of a script/style block: `` does not close it. const rawClose = (name, from) => { const needle = ']/.test(lower[at + needle.length] ?? '')) { at = lower.indexOf(needle, at + 1); } if (at === -1) missing.add(needle); return at; }; let out = ''; let i = 0; while (i < html.length) { const lt = html.indexOf('<', i); if (lt === -1) { out += html.slice(i); break; } out += html.slice(i, lt); i = lt; let end = -1; if (lower.startsWith('', lt + 4); if (close !== -1) end = close + 3; } else { const raw = /^<(script|style)(?![a-z0-9])/.exec(lower.slice(lt, lt + 10)); if (raw) { const close = rawClose(raw[1], lt + 1); const gt = close === -1 ? -1 : lower.indexOf('>', close); if (gt !== -1) end = gt + 1; } if (end === -1 && /[a-z!/]/i.test(html[lt + 1] ?? '')) end = tagEnd(html, lt); } if (end === -1) { out += '<'; i = lt + 1; } else { out += ' '; i = end; } } return out.replace(/ | /gi, ' ').replace(/&/gi, '&'); } function readCaptureText(jdsDir, row) { if (!jdsDir) return null; const nums = linkedReportNumbers(row.report, row.notes); const slug = captureSlug(row.company); // An unusable slug (e.g. the `?` placeholder employer) cannot vouch for a // capture, and a number alone has matched the wrong company before. if (nums.length !== 1 || slug === '') return null; const hit = findCaptureForReport(jdsDir, nums[0], { companySlug: slug }); if (!hit || !READABLE_CAPTURE_EXTS.has(hit.ext.toLowerCase())) return null; try { const buf = readFileSync(hit.path); const text = buf.subarray(0, MAX_CAPTURE_BYTES).toString('utf-8'); return /^\.html?$/i.test(hit.ext) ? htmlToText(text) : text; } catch { return null; } } /** * Compute the closing-soon report from tracker markdown. * * @param {string} trackerMd * @param {object} opts * @param {Date} opts.today - UTC-midnight Date. * @param {number} [opts.within] - Window in days (inclusive of today). * @param {Set} opts.statuses - Canonical status labels to include. * @param {string|null} [opts.jdsDir] - Captures directory; null disables. */ export function computeClosingSoon(trackerMd, { today, within = DEFAULT_WITHIN_DAYS, statuses, jdsDir = null, lookup = loadStateLookup() }) { const lines = String(trackerMd || '').replace(/\r\n/g, '\n').split('\n'); const colmap = resolveColumns(lines); const wanted = new Set([...statuses].map(s => s.toLowerCase())); const closingSoon = []; const overdue = []; const unparsed = []; let rowsChecked = 0; for (const line of lines) { const row = parseTrackerRow(line, colmap); if (!row) continue; const rawStatus = String(row.status || '').replace(/\*\*/g, '').trim(); // Same states.yml lookup as --status, so an alias cell (e.g. `evaluada`) matches. const status = lookup.get(rawStatus.toLowerCase()) ?? rawStatus; if (!wanted.has(status.toLowerCase())) continue; rowsChecked++; let source = 'notes'; let found = extractClosingDates(row.notes, today); // Notes win; the capture is consulted only when Notes carry no marker at // all, so a stale JD boilerplate "deadline" cannot contradict the user. if (found.dates.length === 0 && found.unparsed.length === 0) { const text = readCaptureText(jdsDir, row); if (text) { // JD prose ("deadline-driven team") is not a closing-date claim: only // date-shaped text that cannot be read is worth an `unparsed` entry. found = extractClosingDates(text, today, { strictMarkers: false }); source = 'capture'; } } const base = { num: row.num, company: row.company, role: row.role, status, source }; if (found.dates.length === 0) { if (found.unparsed.length > 0) unparsed.push({ ...base, ...found.unparsed[0], markers: found.unparsed.length }); continue; } // Several dates in one row: surface the earliest, since the cost of an // early warning is a glance and the cost of a late one is a lost posting. const sorted = [...found.dates].sort((a, b) => a.date.localeCompare(b.date)); const first = sorted[0]; const daysLeft = daysBetween(today, parseDate(first.date)); const entry = { ...base, closes: first.date, daysLeft, marker: first.marker }; const distinct = [...new Set(sorted.map(d => d.date))]; if (distinct.length > 1) entry.allDates = distinct; if (daysLeft < 0) { const { daysLeft: _drop, ...rest } = entry; overdue.push({ ...rest, daysOverdue: -daysLeft }); } else if (daysLeft <= within) { closingSoon.push(entry); } } closingSoon.sort((a, b) => a.daysLeft - b.daysLeft || a.num - b.num); overdue.sort((a, b) => a.daysOverdue - b.daysOverdue || a.num - b.num); unparsed.sort((a, b) => a.num - b.num); return { closingSoon, overdue, unparsed, rowsChecked }; } /** * Interview days that closing dates pile up on. * * @param {object[]} closingSoon - `closingSoon` rows from computeClosingSoon. * @param {string} interviewsMd - data/active-interviews.md content. * @param {object} opts * @param {Date} opts.today - UTC-midnight Date. * @param {number} [opts.within] - Same window as the closing-soon report. * @param {number} [opts.clashMin] - Closing rows needed to call it a clash. * @returns {{date: string, interview: {company: string, role: string, time: string}, closing: object[]}[]} * Soonest interview first. Interviews in a terminal Status, or whose Date/Time * cell has no readable leading YYYY-MM-DD, are skipped. */ export function computeClashes(closingSoon, interviewsMd, { today, within = DEFAULT_WITHIN_DAYS, clashMin = DEFAULT_CLASH_MIN }) { const clashes = []; const rows = parseActiveInterviews(String(interviewsMd ?? '').replace(/\r\n/g, '\n')); for (const row of rows) { if (TERMINAL_INTERVIEW_RE.test(findColumn(row, 'status'))) continue; const time = findColumn(row, 'date/time').trim(); const lead = /^(\d{4}-\d{2}-\d{2})(?!\d)/.exec(time); const when = lead ? parseDate(lead[1]) : null; if (!when) continue; const daysAway = daysBetween(today, when); if (daysAway < 0 || daysAway > within) continue; const day = isoDay(when); const eve = isoDay(new Date(when.getTime() - MS_PER_DAY)); const closing = closingSoon.filter(r => r.closes === day || r.closes === eve); if (closing.length < clashMin) continue; clashes.push({ date: day, interview: { company: findColumn(row, 'company').trim(), role: findColumn(row, 'role').trim(), time }, closing, }); } clashes.sort((a, b) => a.date.localeCompare(b.date)); return clashes; } // --- CLI --- const KNOWN_FLAGS = [ '--within', '--status', '--today', '--tracker', '--jds', '--clashes', '--clash-min', '--interviews', '--json', '--summary', '--help', '-h', ]; const VALUE_FLAGS = ['--within', '--status', '--today', '--tracker', '--jds', '--clash-min', '--interviews']; const USAGE = `Usage: node closing-soon.mjs # JSON report (default) node closing-soon.mjs --summary # human-readable table node closing-soon.mjs --within # window in days (default ${DEFAULT_WITHIN_DAYS}) node closing-soon.mjs --status # canonical states to include (default ${DEFAULT_STATUS}) node closing-soon.mjs --today # evaluate as of a fixed date node closing-soon.mjs --tracker # a different applications.md node closing-soon.mjs --jds # a different jds/ captures directory node closing-soon.mjs --clashes # also flag interview days with closings piling up node closing-soon.mjs --clash-min # closings needed for a clash (default ${DEFAULT_CLASH_MIN}; needs --clashes) node closing-soon.mjs --interviews # a different active-interviews.md (needs --clashes) node closing-soon.mjs --help # show this message The report reads Notes (and jds/ captures when Notes have no closing marker) and writes nothing.`; function fail(message) { console.error(message); process.exit(2); } function printSummary(report) { const { metadata, closingSoon, overdue, unparsed, clashes } = report; const clip = (s, n) => (String(s).length > n ? `${String(s).slice(0, n - 1)}\u2026` : String(s)); const table = (rows, lastLabel, lastOf) => { console.log(` ${'#'.padEnd(5)}${'Company'.padEnd(24)}${'Role'.padEnd(30)}${'Status'.padEnd(11)}${'Closes'.padEnd(12)}${lastLabel}`); for (const r of rows) { console.log(` ${String(r.num).padEnd(5)}${clip(r.company, 22).padEnd(24)}${clip(r.role, 28).padEnd(30)}${clip(r.status, 9).padEnd(11)}${r.closes.padEnd(12)}${lastOf(r)}`); } }; console.log(`\n${'='.repeat(86)}`); console.log(' Closing soon \u2014 career-ops'); console.log(` As of ${metadata.today} \u00b7 window ${metadata.within} day(s) \u00b7 status: ${metadata.statuses.join(', ')}`); console.log(`${'='.repeat(86)}\n`); console.log(`Closing within ${metadata.within} day(s): ${closingSoon.length}`); if (closingSoon.length) table(closingSoon, 'In', r => (r.daysLeft === 0 ? 'today' : `${r.daysLeft}d`)); console.log(`\nOverdue (date already passed): ${overdue.length}`); if (overdue.length) table(overdue, 'Ago', r => `${r.daysOverdue}d`); console.log(`\nClosing marker found but date not readable: ${unparsed.length}`); for (const r of unparsed) { console.log(` #${r.num} ${clip(r.company, 22)} \u2014 "${r.text}" (${r.reason}; ${r.source})`); } if (clashes) { console.log(`\nInterview-day clashes: ${clashes.length}`); for (const c of clashes) { const who = [c.interview.company, c.interview.role].filter(Boolean).join(' — '); console.log(` ${c.date} ${who} (${c.interview.time})`); for (const r of c.closing) console.log(` #${r.num} ${clip(r.company, 22)} — ${clip(r.role, 28)} (closes ${r.closes})`); console.log(` -> ${c.closing.length} posting(s) close on or the day before this interview: submit these before the interview.`); } } console.log(`\nRows checked: ${metadata.rowsChecked}\n`); } if (isMainModule(import.meta.url)) { const args = process.argv.slice(2); validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true }); if (hasFlag(args, '--json') && hasFlag(args, '--summary')) { fail('Choose one of --json or --summary.'); } const valueOf = (flag) => (hasFlag(args, flag) ? flagValue(args, flag) : null); let today; const todayArg = valueOf('--today'); if (todayArg !== null) { today = parseDate(todayArg); if (!today) fail(`--today expects YYYY-MM-DD, received "${todayArg}".`); } else { today = parseDate(localToday()); } let within = DEFAULT_WITHIN_DAYS; const withinArg = valueOf('--within'); if (withinArg !== null) { if (!/^\d+$/.test(withinArg) || !Number.isSafeInteger(Number(withinArg))) { fail(`--within expects a non-negative whole number of days, received "${withinArg}".`); } within = Number(withinArg); } const clashesOn = hasFlag(args, '--clashes'); if (!clashesOn && (hasFlag(args, '--clash-min') || hasFlag(args, '--interviews'))) { fail('--clash-min and --interviews only apply together with --clashes.'); } let clashMin = DEFAULT_CLASH_MIN; const clashMinArg = valueOf('--clash-min'); if (clashMinArg !== null) { if (!/^\d+$/.test(clashMinArg) || !Number.isSafeInteger(Number(clashMinArg)) || Number(clashMinArg) < 1) { fail(`--clash-min expects a whole number of at least 1, received "${clashMinArg}".`); } clashMin = Number(clashMinArg); } let statuses; try { statuses = resolveStatusFilter(valueOf('--status') ?? DEFAULT_STATUS); } catch (err) { fail(err.message); } const dataRoot = getCareerOpsRoot(); const trackerPath = valueOf('--tracker') || resolveTrackerPath(dataRoot); const jdsDir = valueOf('--jds') || join(dataRoot, 'jds'); const warnings = []; let trackerMd = ''; if (existsSync(trackerPath)) { trackerMd = readFileSync(trackerPath, 'utf-8'); } else { warnings.push(`No tracker found at ${trackerPath}; nothing to check.`); } const result = computeClosingSoon(trackerMd, { today, within, statuses, jdsDir }); const report = { metadata: { today: isoDay(today), within, statuses: [...statuses], rowsChecked: result.rowsChecked, closingSoon: result.closingSoon.length, overdue: result.overdue.length, unparsed: result.unparsed.length, }, closingSoon: result.closingSoon, overdue: result.overdue, unparsed: result.unparsed, warnings, }; if (clashesOn) { const interviewsDefault = existsSync(join(dataRoot, 'data/active-interviews.md')) ? join(dataRoot, 'data/active-interviews.md') : join(dataRoot, 'active-interviews.md'); // A relative --interviews override is resolved from the data root, not the cwd; // an absolute one is kept as given. const interviewsOverride = valueOf('--interviews'); const interviewsPath = interviewsOverride ? resolve(dataRoot, interviewsOverride) : interviewsDefault; let interviewsMd = ''; if (existsSync(interviewsPath)) { try { interviewsMd = readFileSync(interviewsPath, 'utf-8'); } catch (err) { warnings.push(`Cannot read interviews file at ${interviewsPath} (${err.code || err.message}); no clashes to check.`); } } else { warnings.push(`No interviews file found at ${interviewsPath}; no clashes to check.`); } report.clashes = computeClashes(result.closingSoon, interviewsMd, { today, within, clashMin }); } if (hasFlag(args, '--summary')) { printSummary(report); for (const w of warnings) console.log(w); } else { console.log(JSON.stringify(report, null, 2)); } }