[object Object]

← 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

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 →