// OPT / STEM OPT unemployment day counter. MIT License. No dependencies. // Same rules as opt_counter.py. Dates are "YYYY-MM-DD" strings, turned into // whole UTC day numbers from their Y-M-D integers, so DST can never shift a day. const DISCLAIMER = "Counts dates only. It does not decide whether a job qualifies or whether you are in status."; function dayNum(iso) { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); if (!m) throw new Error(`Expected YYYY-MM-DD, got ${iso}`); const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]; const ms = Date.UTC(y, mo - 1, d); const check = new Date(ms); if (check.getUTCMonth() !== mo - 1 || check.getUTCDate() !== d) throw new Error(`Not a real date: ${iso}`); return ms / 86400000; } function isoOf(n) { return new Date(n * 86400000).toISOString().slice(0, 10); } // jobs: [{ start: "YYYY-MM-DD", end: "YYYY-MM-DD" | null, qualifying: true | false }] // end = null means the job is ongoing. function countUnemployment(eadStart, authEnd, asOf, jobs, stemStart = null) { const ead = dayNum(eadStart); const countTo = Math.min(dayNum(asOf), dayNum(authEnd)); const stem = stemStart ? dayNum(stemStart) : null; const covered = new Set(); // a Set, so overlapping jobs never double count for (const job of jobs) { if (!job.qualifying) continue; const end = job.end == null ? countTo : Math.min(dayNum(job.end), countTo); for (let d = Math.max(dayNum(job.start), ead); d <= end; d++) covered.add(d); } const unemployed = []; let firstOver = null; for (let d = ead; d <= countTo; d++) { if (covered.has(d)) continue; unemployed.push(d); const limitThatDay = stem !== null && d >= stem ? 150 : 90; if (firstOver === null && unemployed.length > limitThatDay) firstOver = d; } const limit = stem !== null && countTo >= stem ? 150 : 90; return { days_used: unemployed.length, limit, days_remaining: limit - unemployed.length, first_day_over_limit: firstOver === null ? null : isoOf(firstOver), unemployed_dates: unemployed.map(isoOf), }; } module.exports = { countUnemployment, DISCLAIMER };