← back to Sample Followup Sweep
sample-followup: day-of-week-aware window (Tue 4-day catch-up, Wed-Fri strict) — TK-12091
a11a644c5ba87dcb86b162ba38fd83e1b769d549 · 2026-09-23 14:36:55 -0700 · Steve Abrams
Replaces the pure single-day filter (e3a7a73). windowRange (scheduled-run.mjs),
WIN (send-reconciled.mjs), and lib/sweep.js now: hi=today-10 always; TUE widens
lo to today-13 (covers Sat/Sun/Mon/Tue 10th-day landings), Wed/Thu/Fri (+manual
off-days) strict single day. New test/sweep-dow.test.cjs asserts (a) Tue picks
Sat/Sun/Mon/Tue cohorts, (b) Wed/Thu/Fri pick only today-10, (c) Fri->Tue->Wed
selects every Entered date exactly once (no gap/dup). WIN/CATCHUP/WIDEN_DAYS kept
as manual recovery nets. pre-send-gate + human batch-send untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M lib/sweep.jsM scripts/scheduled-run.mjsM scripts/send-reconciled.mjsA test/sweep-dow.test.cjs
Diff
commit a11a644c5ba87dcb86b162ba38fd83e1b769d549
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 23 14:36:55 2026 -0700
sample-followup: day-of-week-aware window (Tue 4-day catch-up, Wed-Fri strict) — TK-12091
Replaces the pure single-day filter (e3a7a73). windowRange (scheduled-run.mjs),
WIN (send-reconciled.mjs), and lib/sweep.js now: hi=today-10 always; TUE widens
lo to today-13 (covers Sat/Sun/Mon/Tue 10th-day landings), Wed/Thu/Fri (+manual
off-days) strict single day. New test/sweep-dow.test.cjs asserts (a) Tue picks
Sat/Sun/Mon/Tue cohorts, (b) Wed/Thu/Fri pick only today-10, (c) Fri->Tue->Wed
selects every Entered date exactly once (no gap/dup). WIN/CATCHUP/WIDEN_DAYS kept
as manual recovery nets. pre-send-gate + human batch-send untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
lib/sweep.js | 12 ++++++-----
scripts/scheduled-run.mjs | 19 ++++++++++-------
scripts/send-reconciled.mjs | 18 +++++++++-------
test/sweep-dow.test.cjs | 51 +++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 81 insertions(+), 19 deletions(-)
diff --git a/lib/sweep.js b/lib/sweep.js
index 70ec5cb..fb0fb83 100644
--- a/lib/sweep.js
+++ b/lib/sweep.js
@@ -13,16 +13,18 @@ function ageDays(entered, today) {
function sweep(rows, opts = {}, today = new Date()) {
const {
minAgeDays = 10,
- // TK-12087 (Steve 2026-09-23): SINGLE-DAY rolling window. Chase ONLY samples entered EXACTLY
- // minAgeDays calendar days ago — NOT the >10-day cumulative backlog. widenDays>0 extends the
- // window's OLD end as an explicit recovery net (dedup makes overlap safe). maxAgeDays kept only
- // as the absolute dead-lead ceiling for any widened/manual window.
- widenDays = 0,
maxAgeDays = 60, // Steve 2026-08-14: >60 days = dead lead, don't chase
excludeDisco = true,
requireNotReceived = true,
secondRequestPolicy = 'escalate', // 'escalate' (default A) | 'include'
} = opts;
+ // TK-12091 (Steve 2026-09-23): DAY-OF-WEEK-AWARE window. A follow-up fires the day a sample's
+ // Entered date hits its 10th CALENDAR day, mapped onto the Tue-Fri run schedule so every landing
+ // is chased exactly once. On a TUESDAY run, widen 3 days back so [minAgeDays .. minAgeDays+3]
+ // (ages 10..13) sweeps up the Sat(−13)/Sun(−12)/Mon(−11) 10th-day landings plus Tue(−10) itself;
+ // Wed/Thu/Fri (and any manual off-day) stay STRICT single-day (age == minAgeDays). An explicit
+ // opts.widenDays overrides the auto value (manual recovery net); dedup makes any overlap safe.
+ const widenDays = (opts.widenDays != null) ? opts.widenDays : (today.getDay() === 2 ? 3 : 0);
const followUp = [];
const escalation = [];
diff --git a/scripts/scheduled-run.mjs b/scripts/scheduled-run.mjs
index 93df18e..c4197ca 100644
--- a/scripts/scheduled-run.mjs
+++ b/scripts/scheduled-run.mjs
@@ -131,13 +131,18 @@ function windowRange(today = new Date()) {
const lo = new Date(today); lo.setDate(lo.getDate() - MIN_AGE_DAYS - (CATCHUP_DAYS - 1));
return `${fmtDate(lo)}...${fmtDate(hi)}`;
}
- // TK-12087 (Steve 2026-09-23): SINGLE-DAY rolling window — chase ONLY the cohort entered EXACTLY
- // today-MIN_AGE_DAYS calendar days ago, NOT the >10-day cumulative backlog [10..60] that the
- // TK-12013/12014 change introduced (reverted here). Each daily run drafts only that day's
- // newly-10-day-old batch. The FileMaker Sent-stamp set-difference is still the dedup, and CATCHUP /
- // WIN remain as explicit manual recovery nets if a run day is ever missed.
- const d = new Date(today); d.setDate(d.getDate() - MIN_AGE_DAYS); // today-10, single calendar day
- return `${fmtDate(d)}...${fmtDate(d)}`;
+ // TK-12091 (Steve 2026-09-23): DAY-OF-WEEK-AWARE window. A follow-up fires the day a sample's
+ // Entered date reaches its 10th CALENDAR day, mapped onto the Tue-Fri run schedule so every
+ // landing-day is covered exactly once (no gap, no double-chase):
+ // • TUESDAY → 4-day catch-up: Entered ∈ [today-13 .. today-10] — covers the 10th-days that
+ // landed on the preceding Sat(−13)/Sun(−12)/Mon(−11) plus Tue(−10) itself.
+ // • WED/THU/FRI → STRICT single day: Entered == today-10.
+ // • any other weekday (Sat/Sun/Mon, manual runs only) → STRICT single day (never re-chase).
+ // hi is always today-10; only Tuesday widens the low end. CATCHUP/WIN stay as manual recovery nets.
+ const span = (today.getDay() === 2) ? 3 : 0; // Tue widens 3 days back
+ const hi = new Date(today); hi.setDate(hi.getDate() - MIN_AGE_DAYS); // today-10
+ const lo = new Date(today); lo.setDate(lo.getDate() - MIN_AGE_DAYS - span); // today-10-span
+ return `${fmtDate(lo)}...${fmtDate(hi)}`;
}
const norm = (s) => String(s || '').toLowerCase().trim();
const readJSON = (f, d) => { try { return JSON.parse(readFileSync(join(ROOT, f), 'utf8')); } catch { return d; } };
diff --git a/scripts/send-reconciled.mjs b/scripts/send-reconciled.mjs
index f5a36f0..8587122 100644
--- a/scripts/send-reconciled.mjs
+++ b/scripts/send-reconciled.mjs
@@ -38,14 +38,18 @@ const _fmt = (d) => `${_pad(d.getMonth() + 1)}/${_pad(d.getDate())}/${d.getFullY
// Steve 2026-09-22 (TK-12013/TK-12014): full active window [today-MAX_AGE .. today-MIN_AGE] inclusive,
// NOT a narrow rolling catch-up band — so no older still-outstanding memo is ever dropped. Draft-dedup
// (reconciled-draft-ledger keyed by vendor+combo-sku) makes re-covering already-drafted memos a no-op.
-// TK-12087 (Steve 2026-09-23): SINGLE-DAY rolling window. Chase ONLY samples entered EXACTLY
-// today-10 CALENDAR days ago — NOT the >10-day cumulative backlog (the TK-12013/12014 change,
-// reverted here). Each daily run drafts only that day's newly-10-day-old batch. WIN overrides for
-// a manual one-off; WIDEN_DAYS>0 widens the low end as an explicit recovery net (dedup makes it safe).
+// TK-12091 (Steve 2026-09-23): DAY-OF-WEEK-AWARE window (mirrors scheduled-run.mjs windowRange).
+// A follow-up fires the day a sample's Entered date hits its 10th CALENDAR day, mapped onto the
+// Tue-Fri run schedule so every landing-day is chased exactly once:
+// • TUESDAY → 4-day catch-up: Entered ∈ [today-13 .. today-10] (covers Sat/Sun/Mon/Tue landings)
+// • WED/THU/FRI (+ any manual off-day) → STRICT single day: Entered == today-10
+// hi is always today-10; only Tuesday widens the low end. WIN fully overrides; WIDEN_DAYS>0 is an
+// explicit manual recovery net that widens the low end further (dedup makes any overlap safe).
const _MIN_AGE = 10;
-const _WIDEN = Number(process.env.WIDEN_DAYS) || 0; // 0 = strict single day (today-10 only)
-const _hi = new Date(); _hi.setDate(_hi.getDate() - _MIN_AGE); // today-10
-const _lo = new Date(); _lo.setDate(_lo.getDate() - _MIN_AGE - _WIDEN); // = today-10 when WIDEN=0
+const _now = new Date();
+const _span = Math.max((_now.getDay() === 2 ? 3 : 0), Number(process.env.WIDEN_DAYS) || 0);
+const _hi = new Date(); _hi.setDate(_hi.getDate() - _MIN_AGE); // today-10
+const _lo = new Date(); _lo.setDate(_lo.getDate() - _MIN_AGE - _span); // today-10-span
const WIN = process.env.WIN || `${_fmt(_lo)}...${_fmt(_hi)}`;
// --- FileMaker: pull outstanding (Date WP Sample Sent empty) in WIN, group by vid ---
diff --git a/test/sweep-dow.test.cjs b/test/sweep-dow.test.cjs
new file mode 100644
index 0000000..d21136c
--- /dev/null
+++ b/test/sweep-dow.test.cjs
@@ -0,0 +1,51 @@
+'use strict';
+// TK-12091 day-of-week-aware window unit test.
+// Rule: hi=today-10 always; TUE widens lo to today-13 (4-day catch-up), Wed/Thu/Fri strict single day.
+const assert = require('node:assert');
+const { sweep } = require('../lib/sweep');
+
+const D = (s) => new Date(s + 'T12:00:00'); // local noon, avoids TZ edge
+const dstr = (d) => d.toISOString().slice(0, 10);
+const before = (base, n) => { const d = new Date(base); d.setDate(d.getDate() - n); return dstr(d); }; // n days BEFORE
+const row = (entered) => ({ mfr: 'M' + entered, entered });
+const pick = (today, rows) => sweep(rows, {}, today).followUp.map(r => r.entered).sort();
+
+assert.equal(D('2026-09-22').getDay(), 2, '2026-09-22 must be Tuesday');
+assert.equal(D('2026-09-23').getDay(), 3, '2026-09-23 must be Wednesday');
+assert.equal(D('2026-09-18').getDay(), 5, '2026-09-18 must be Friday');
+
+// (a) TUESDAY run picks up Sat/Sun/Mon/Tue landing cohorts = Entered today-13..today-10, excludes -14 and -9
+{
+ const t = D('2026-09-22');
+ const rows = [9,10,11,12,13,14].map(n => row(before(t, n)));
+ const got = pick(t, rows);
+ const want = [13,12,11,10].map(n => before(t, n)).sort();
+ assert.deepEqual(got, want, `(a) Tuesday window wrong: got ${got}`);
+ console.log('(a) PASS — Tuesday picks Sat(-13),Sun(-12),Mon(-11),Tue(-10):', got.join(', '), '| excluded -14 & -9');
+}
+
+// (b) WED/THU/FRI run picks ONLY today-10, nothing older
+for (const [label, day] of [['WED','2026-09-23'],['THU','2026-09-24'],['FRI','2026-09-25']]) {
+ const t = D(day);
+ const rows = [9,10,11,12,13].map(n => row(before(t, n)));
+ const got = pick(t, rows);
+ const want = [before(t, 10)];
+ assert.deepEqual(got, want, `(b) ${label} must pick ONLY today-10: got ${got}`);
+ console.log(`(b) PASS — ${label} picks ONLY today-10:`, got.join(', '));
+}
+
+// (c) Fri -> Tue -> Wed sequence: every Entered date selected exactly once (no gap, no dup)
+{
+ const fri = D('2026-09-18'), tue = D('2026-09-22'), wed = D('2026-09-23');
+ const cand = []; for (let n = 5; n <= 15; n++) cand.push(before(wed, n)); // 09/08 .. 09/18
+ const rows = cand.map(row);
+ const counts = {};
+ for (const t of [fri, tue, wed]) for (const e of pick(t, rows)) counts[e] = (counts[e] || 0) + 1;
+ const selected = Object.keys(counts).sort();
+ const expected = ['2026-09-08','2026-09-09','2026-09-10','2026-09-11','2026-09-12','2026-09-13'];
+ assert.deepEqual(selected, expected, `(c) selected set wrong: ${selected}`);
+ const dups = Object.entries(counts).filter(([, c]) => c !== 1);
+ assert.equal(dups.length, 0, `(c) double-chase found: ${JSON.stringify(dups)}`);
+ console.log('(c) PASS — Fri->Tue->Wed selects each Entered once, no gap/dup:', selected.join(', '));
+}
+console.log('\nALL 3 ASSERTIONS PASSED');
← e3a7a73 sample-followup: single-day rolling window (Entered==today-1
·
back to Sample Followup Sweep
·
sample-followup: exclude MDCKEN as non-vendor no-chase (TK-1 7534717 →