← back to Builds Nightly
lib/wins-for-day.mjs
87 lines
#!/usr/bin/env node
// Pull CNCP wins for ONE calendar day (local time) and shape them into
// { date, dateLabel, count, bullets[], narration } for the nightly video.
// Usage: node lib/wins-for-day.mjs 2026-07-22 → prints JSON to stdout.
import http from 'node:http';
const CNCP = process.env.CNCP_URL || 'http://127.0.0.1:3333/api/wins';
const day = process.argv[2];
if (!/^\d{4}-\d{2}-\d{2}$/.test(day || '')) {
console.error('usage: wins-for-day.mjs YYYY-MM-DD');
process.exit(2);
}
function getJSON(url) {
return new Promise((resolve, reject) => {
const req = http.get(url, { timeout: 12000 }, (res) => {
let b = '';
res.on('data', (c) => (b += c));
res.on('end', () => { try { resolve(JSON.parse(b)); } catch (e) { reject(e); } });
});
req.on('timeout', () => req.destroy(new Error('timeout')));
req.on('error', reject);
});
}
// A win's timestamp, best-effort across the field names CNCP has used.
function winTime(w) {
for (const k of ['created_at', 'ts', 'date', 'time', 'createdAt', 'at']) {
if (w[k]) { const d = new Date(String(w[k])); if (!isNaN(d)) return d; }
}
return null;
}
// Local-time calendar-day key (YYYY-MM-DD) for a Date.
function dayKey(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim();
const data = await getJSON(CNCP).catch((e) => { console.error('CNCP unreachable:', e.message); process.exit(9); });
const wins = Array.isArray(data) ? data : (data.wins || data.items || []);
const dayWins = [];
for (const w of wins) {
// CNCP stamps a clean 'date' string (YYYY-MM-DD) — use it verbatim (no TZ math).
// Fall back to parsing a timestamp only when 'date' is absent.
const dstr = typeof w.date === 'string' && /^\d{4}-\d{2}-\d{2}/.test(w.date) ? w.date.slice(0, 10) : null;
if (dstr) { if (dstr === day) dayWins.push(w); continue; }
const t = winTime(w);
if (t && dayKey(t) === day) dayWins.push(w);
}
// Dedupe by title; keep first-seen order.
const seen = new Set();
const uniq = [];
for (const w of dayWins) {
const title = clean(w.title || w.name || w.summary);
if (!title || seen.has(title.toLowerCase())) continue;
seen.add(title.toLowerCase());
uniq.push({ title, project: clean(w.project || w.area || '') });
}
const dt = new Date(day + 'T12:00:00');
const dateLabel = dt.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
const shortLabel = dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
// On-screen: up to 8 bullets. Narration: a flowing recap, capped ~780 chars
// (ElevenLabs turbo does ~15 chars/sec → keeps clips ~45-70s).
const bullets = uniq.slice(0, 8);
let narration = '';
if (uniq.length === 0) {
narration = '';
} else {
const n = uniq.length;
const lead = `${dateLabel}. ${n === 1 ? 'One build landed' : `${n} builds landed`} today.`;
const parts = [lead];
for (const w of uniq) {
if (parts.join(' ').length > 720) break;
parts.push(w.project ? `${w.title} on ${w.project}.` : `${w.title}.`);
}
narration = parts.join(' ').slice(0, 800);
}
console.log(JSON.stringify({
date: day, dateLabel, shortLabel,
count: uniq.length, bullets, narration,
}, null, 1));