← back to Dw Activation Calendar
scripts/generate-schedule.js
166 lines
'use strict';
/*
* generate-schedule.js — assign a DURABLE date + time (go-live) to EVERY staged
* SKU waiting to go active, and write it to data/activation-schedule.json.
*
* WHY a JSON file (not a PG table): dw_unified's publication `dw_unified_pub` is
* FOR ALL TABLES, so ANY new table auto-joins logical replication to Kamatera and
* could stall the subscriber's apply worker. A JSON file keeps the schedule
* local-only, reversible, and non-canonical — exactly the DTD-AX intent — with
* zero replication blast-radius. (dw_unified is still READ, never written.)
*
* FAITHFUL TO REALITY: the order and cadence now mirror the UNIFIED rotation
* (2026-07-21 redesign — textures-first + vendor round-robin across ALL 15,863
* staged DRAFTs, not just the ~5k field-fix worklist). The order is imported from
* lib/rotation-order.js (ROTATION_ORDER_SQL) — the SAME canonical query the
* rotation activator (dw-rotation-activator/rotate-activate.js) uses to actually
* flip DRAFT→ACTIVE — so the projected calendar and the live activation order are
* identical:
* - order = mat_tier 0 (textures/naturals) FIRST, then mat_tier 1 (rest);
* within each tier a breadth-first vendor round-robin (rr) so each
* increment spreads one-per-vendor (never a single-vendor block).
* - cadence = HARD 500/day, spread across the 24 hourly slots that fire at :10,
* ~30/slot (150 in the 21-23 reclaim slots).
* So each SKU's go_live_at is the concrete date + HH:10 slot the rotation will
* actually activate it in — not an invented time.
*
* COVERAGE: ALL staged DRAFTs (Schumacher excluded — internal-only; help-doc
* noise rows excluded). Two lanes drain the same ordered queue — the field-fix
* drain builds missing variants for the ~5k incomplete drafts, and the rotation
* activator flips the already-complete drafts live — but the EFFECTIVE order is
* this one, unified.
*
* Re-run any time to re-project against the current DRAFT set (100% coverage,
* deterministic). Every staged DRAFT gets exactly one (date, time).
*/
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
const { ROTATION_ORDER_SQL } = require('../lib/rotation-order.js');
const PER_DAY = parseInt(process.env.SCHED_PER_DAY, 10) || 500; // budget.cjs backlog hard cap
const SLOT_MIN = 10; // drain fires at HH:10
const hourCap = (h) => (h >= 21 ? 150 : 30); // drain.sh SLOT_MAX (reclaim 150 in 21-23)
const pool = process.env.DATABASE_URL
? new Pool({ connectionString: process.env.DATABASE_URL, max: 4 })
: new Pool({ host: process.env.PGHOST || '/tmp', database: process.env.PGDATABASE || 'dw_unified',
user: process.env.PGUSER || process.env.USER || 'stevestudio2', max: 4 });
// UNIFIED rotation order (2026-07-21) — imported from lib/rotation-order.js so the
// calendar projects the EXACT order the rotation activator activates in:
// textures-first (mat_tier 0) then rest (mat_tier 1), breadth-first vendor
// round-robin (rr) within each tier. Schumacher + help-doc noise excluded there.
// This covers ALL staged DRAFTs (status='DRAFT' in shopify_products), not just the
// ~5k field-fix worklist. Already-ACTIVE products fall out naturally (the query
// selects status='DRAFT' only), satisfying Steve's "don't re-stage live products"
// rule. shopify_id is the durable per-item identity + image/title join key.
// (dw_unified still READ-only; nothing is written back to it.)
const RR_SQL = ROTATION_ORDER_SQL;
// which hourly :10 slot a within-day position lands in (0-based pos within the day)
function slotHourForPos(pos) {
let cum = 0;
for (let h = 0; h < 24; h++) {
const cap = hourCap(h);
if (pos < cum + cap) return h;
cum += cap;
}
return 23; // unreachable under a 500/day cap, but safe
}
async function main() {
const c = await pool.connect();
let rows;
try {
rows = (await c.query(RR_SQL)).rows;
} finally { c.release(); await pool.end(); }
const total = rows.length;
if (total === 0) { console.error('No pending schedulable SKUs — nothing to schedule.'); process.exit(2); }
// Start at the next full drain day (tomorrow 00:00 local) — matches the calendar's
// existing convention and avoids over-fitting today's partially-spent budget.
const start = new Date(); start.setHours(0, 0, 0, 0); start.setDate(start.getDate() + 1);
const pad = (n) => String(n).padStart(2, '0');
const out = new Array(total);
for (let seq = 0; seq < total; seq++) {
const r = rows[seq];
const day = Math.floor(seq / PER_DAY);
const pos = seq % PER_DAY;
const hour = slotHourForPos(pos);
// concrete local wall-clock instant: (start + day) at HH:10
const dt = new Date(start.getFullYear(), start.getMonth(), start.getDate() + day, hour, SLOT_MIN, 0, 0);
const go_live_date = `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}`;
out[seq] = {
shopify_id: r.shopify_id, // durable per-item identity + image/title join key
dw_sku: r.dw_sku || '', // may be blank on some rows
title: r.title || '', // label (shared query returns the live title)
product_type: r.product_type || '',
mat_tier: Number(r.mat_tier), // 0 = textures/naturals (activated first), 1 = rest
rr: Number(r.rr),
seq,
vendor: r.vendor,
slot_hour: hour,
go_live_date,
go_live_at: dt.toISOString(), // exact instant (UTC); UI renders back to local
};
}
// Apply MANUAL MOVES (drag-to-reschedule) so a regenerate keeps the user's
// hand-placed dates instead of overwriting them with the computed cadence.
try {
const ov = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'schedule-overrides.json'), 'utf8'));
let applied = 0;
for (const s of out) {
const o = ov[s.shopify_id] || ov[s.dw_sku];
if (o && o.go_live_date) {
s.go_live_date = o.go_live_date;
if (o.slot_hour != null) s.slot_hour = o.slot_hour;
const [Y, M, D] = o.go_live_date.split('-').map(Number);
s.go_live_at = new Date(Y, M - 1, D, s.slot_hour, 10, 0, 0).toISOString();
s.moved = true; applied++;
}
}
if (applied) console.log(` applied ${applied} manual move override(s)`);
} catch { /* no overrides file — nothing to apply */ }
// Coverage assertion — EVERY staged item MUST have a date+time (Steve's requirement).
// Keyed on the row (seq), not dw_sku: dw_sku is not unique and can be blank.
const withDT = out.filter(s => s.go_live_at && s.go_live_date).length;
const uniqueSeq = new Set(out.map(s => s.seq)).size;
if (withDT !== total || uniqueSeq !== total) {
console.error(`COVERAGE FAIL: total=${total} withDateTime=${withDT} uniqueSeq=${uniqueSeq}`);
process.exit(3);
}
const distinctProducts = new Set(out.map(s => s.shopify_id)).size;
const blankSkuItems = out.filter(s => !s.dw_sku).length;
const tier0Items = out.filter(s => s.mat_tier === 0).length;
const meta = {
generated_at: new Date().toISOString(),
total_items: total, // every staged DRAFT = one drain unit (one slot)
distinct_products: distinctProducts,
tier0_textures: tier0Items, // textures/naturals activated FIRST
tier1_rest: total - tier0Items,
blank_sku_items: blankSkuItems, // rows without a dw_sku (keyed by shopify_id)
per_day: PER_DAY,
slot_minute: SLOT_MIN,
start_date: `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}`,
end_date: out[total - 1].go_live_date,
days_to_clear: Math.ceil(total / PER_DAY),
coverage: `${withDT}/${total}`,
source: 'ALL staged DRAFTs via lib/rotation-order.js (textures-first + vendor round-robin, Schumacher+noise excluded) @ 500/day hourly :10 slots',
};
const dataDir = path.join(__dirname, '..', 'data');
fs.mkdirSync(dataDir, { recursive: true });
const file = path.join(dataDir, 'activation-schedule.json');
fs.writeFileSync(file, JSON.stringify({ meta, skus: out }, null, 0));
console.log(`✓ wrote ${file}`);
console.log(` ${meta.coverage} SKUs stamped with date+time · ${meta.days_to_clear} days · ${meta.start_date} → ${meta.end_date}`);
}
main().catch(e => { console.error('generate-schedule failed:', e.message); process.exit(1); });