← back to Commercialrealestate

scripts/afternoon-report.js

134 lines

// afternoon-report.js — the 1pm "what's new since this morning" report.
// Reads ranked.json (re-ranked after the 1pm Crexi re-scrape) plus the pre-scrape id snapshot
// (data/.pre-afternoon-ids.json, written by run-afternoon-report.sh BEFORE refresh-listings.js)
// and surfaces TWO things Steve asked for:
//   1. ADDITIONAL LISTINGS  — listings whose id wasn't present before the 1pm scrape (genuinely new to the ranker)
//   2. RED-HOT OPPORTUNITIES — active + in-budget deals with standout economics (high composite / strong CoC / AI "Buy")
// Builds HTML -> data/afternoon-report.html and emails it to steve-office + steve-personal via George's
// local /api/send (both recipients are George-internal, so the external-send guard passes; no claude -p,
// no paid API — the only cost in the whole 1pm job is the Browserbase scrape upstream).
const fs = require('fs');
const path = require('path');
const http = require('http');
const ROOT = path.join(__dirname, '..');
const CREFin = require('../public/finance.js');
const EX = require('./report-extras.js'); // chart + table-of-info + suggested data points (2026-06-26)

const { meta, assumptions, ranked } = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'ranked.json'), 'utf8'));
const A = assumptions || { budget: 400000, downPct: 25, ratePct: 6.75, amortYears: 30, closingPct: 2.5 };

// pre-scrape snapshot of listing ids (written by the shell before refresh). Missing/empty => treat as "no baseline"
let preIds = new Set();
let hadBaseline = false;
try {
  const snap = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', '.pre-afternoon-ids.json'), 'utf8'));
  if (Array.isArray(snap.ids)) { preIds = new Set(snap.ids); hadBaseline = snap.ids.length > 0; }
} catch (_) {}

// recompute finance at the canonical 25%-down scenario (same as morning-report.js)
const rows = ranked.map(p => {
  const f = CREFin.finance(p, A);
  const fs2 = CREFin.financeScore(p, f);
  return { ...p, finance: f, composite: CREFin.composite(p, f, fs2.score, p.qwenScore) };
});
const isActive = p => !/Under Contract|Pending|OUT OF BUDGET/i.test(p.status || '');

// 1) ADDITIONAL LISTINGS: in the current ranker but not in the pre-scrape snapshot
const newListings = hadBaseline ? rows.filter(p => !preIds.has(p.id)) : [];
newListings.sort((a, b) => b.composite - a.composite);

// 2) RED-HOT: active + affordable + standout economics. Exclude nothing by recency — a hot deal is hot
//    whether it's new or has been sitting. Bar = high blended score OR strong cash-on-cash OR AI says Buy.
const isHot = p => isActive(p) && p.finance.affordable &&
  (p.composite >= 68 || (p.finance.coc != null && p.finance.coc >= 4) || (p.qwen && p.qwen.recommendation === 'Buy'));
let redHot = rows.filter(isHot).sort((a, b) => b.composite - a.composite).slice(0, 6);
// Fallback: if nothing clears the bar, show the 3 best active in-budget deals so the section is never empty.
let hotFallback = false;
if (!redHot.length) {
  hotFallback = true;
  redHot = rows.filter(p => isActive(p) && p.finance.affordable).sort((a, b) => b.composite - a.composite).slice(0, 3);
}

const today = process.env.REPORT_DATE || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Los_Angeles' });
const fmt = n => n == null ? '—' : '$' + Math.round(n).toLocaleString();

const card = (p, badge) => {
  const f = p.finance;
  const ver = p.verified === true ? '✓ verified in-place' : (p.cap_rate != null ? 'broker cap' : 'no income data');
  const cap = p.cap_rate != null ? p.cap_rate + '%' : '—';
  const posLev = f.coc != null && f.coc > 0;
  return `<div style="border-top:1px solid #e2e2e2;padding:10px 0">
    <div style="font-size:14px">${badge || ''}<b><a href="${p.source}">${p.address}</a>, ${p.city}</b> &nbsp;<span style="color:#888;font-weight:400">score ${p.composite}</span></div>
    <div style="color:#666;font-size:12px">${p.type} · ${p.units} unit${p.units > 1 ? 's' : ''}${p.year_built ? ` · built ${p.year_built}` : ''} · ${fmt(p.price)} · cap ${cap} (${ver})</div>
    <div style="font-size:12px;color:#333;margin:3px 0">Cash to close <b>${fmt(f.cashNeeded)}</b> (${f.affordable ? 'in budget' : 'OVER budget'}) · CoC <span style="color:${posLev ? '#178a3a' : '#b00'}">${f.coc != null ? f.coc + '%' : 'n/a'}</span> · DSCR ${f.dscr ?? 'n/a'} · est NOI ${f.noi ? fmt(f.noi) : 'n/d'}</div>
    ${p.qwen && p.qwen.thesis ? `<div style="font-size:13px;color:#222;margin:4px 0">${p.qwen.thesis}</div>` : ''}
    ${p.qwen && p.qwen.risks && p.qwen.risks.length ? `<div style="font-size:12px;color:#a15c00;margin:4px 0"><b>Risks:</b> ${p.qwen.risks.slice(0, 3).join('; ')}</div>` : ''}
    <div style="font-size:12px;color:#555">${p.upside_note || ''}</div>
  </div>`;
};
const NEW_BADGE = '<span style="background:#0a7d2c;color:#fff;font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;margin-right:6px;vertical-align:middle">NEW</span>';
const HOT_BADGE = '<span style="background:#c81e1e;color:#fff;font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;margin-right:6px;vertical-align:middle">🔥 HOT</span>';

const newSection = !hadBaseline
  ? `<p style="font-size:13px;color:#a15c00;margin:8px 0">No pre-scrape baseline was available this run, so "new since morning" couldn't be computed. (Resolves automatically on the next 1pm run.)</p>`
  : newListings.length
    ? newListings.map(p => card(p, NEW_BADGE)).join('\n')
    : `<p style="font-size:13px;color:#444;margin:8px 0">No new SFV listings hit Crexi since this morning's run. The ranker still tracks ${rows.length} listings; red-hot standouts below.</p>`;

const hotSection = redHot.length
  ? (hotFallback ? `<p style="font-size:12px;color:#a15c00;margin:6px 0">Nothing cleared the red-hot bar today — showing the best active, in-budget deals instead.</p>` : '')
    + redHot.map(p => card(p, HOT_BADGE)).join('\n')
  : `<p style="font-size:13px;color:#444;margin:8px 0">No active in-budget deals to flag right now.</p>`;

const html = `<div style="font-family:-apple-system,Segoe UI,Arial,sans-serif;max-width:780px">
<h2 style="margin:0 0 2px">SFV CRE — 1pm Update: New Listings &amp; Red-Hot Deals</h2>
<div style="color:#666;font-size:13px">${today} · ${meta.market} · $${A.budget.toLocaleString()} down (${A.downPct}% / ${A.ratePct}% / ${A.amortYears}yr) · ${rows.length} listings tracked</div>
<p style="font-size:13px;color:#444;margin:12px 0">Afternoon re-scrape of Crexi vs this morning. Cap rates are broker/MLS-stated — verify NOI in DD.
Most SFV multifamily runs negative/breakeven leverage at today's rates; positive-leverage (green CoC) or seller-financed deals are the standouts.</p>

<h3 style="margin:16px 0 4px;font-size:15px">🆕 Additional listings since this morning${hadBaseline ? ` (${newListings.length})` : ''}</h3>
${newSection}

<h3 style="margin:20px 0 4px;font-size:15px">🔥 Red-hot opportunities (${redHot.length})</h3>
${hotSection}

${EX.barChart(rows, { n: 8, title: 'Top SFV deals by blended score' })}

${EX.dataTable(rows, { title: 'Table of info — all tracked SFV listings' })}

${EX.moreDataPoints()}

<p style="color:#888;font-size:12px;margin-top:16px">Live viewer: http://127.0.0.1:9911 · 6am report covers the full Top-10 · generated by commercialrealestate/scripts/afternoon-report.js</p>
</div>`;

fs.writeFileSync(path.join(ROOT, 'data', 'afternoon-report.html'), html);

if (process.env.DRY_RUN) {
  console.log(`DRY_RUN: built afternoon-report.html — baseline=${hadBaseline} new=${hadBaseline ? newListings.length : 'n/a'} redHot=${redHot.length}${hotFallback ? ' (fallback)' : ''}; not sending.`);
  process.exit(0);
}

// ---- send via George local API (both recipients are internal; guard passes) ----
const payload = JSON.stringify({
  account: 'steve-office',
  to: 'steve@designerwallcoverings.com, steveabramsdesigns@gmail.com',
  subject: `SFV CRE 1pm — ${hadBaseline ? newListings.length : '?'} new · ${redHot.length} red-hot (${today})`,
  body: html,
});
const req = http.request({
  host: '127.0.0.1', port: 9850, path: '/api/send', method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Content-Length': Buffer.byteLength(payload),
    'Authorization': 'Basic ' + Buffer.from('admin:').toString('base64'),
  },
}, res => {
  let d = ''; res.on('data', c => d += c);
  res.on('end', () => {
    console.log('George /api/send ->', res.statusCode, d);
    process.exit(res.statusCode === 200 ? 0 : 1);
  });
});
req.on('error', e => { console.error('send error:', e.message); process.exit(1); });
req.write(payload); req.end();