← back to Commercialrealestate
Add daily morning-report generator (top 10 SFV CRE opportunities -> HTML email body)
de42fa5c22305b8aaefaf38e24b7905567f94b54 · 2026-06-21 18:01:53 -0700 · Steve
Files touched
M .gitignoreA scripts/morning-report.js
Diff
commit de42fa5c22305b8aaefaf38e24b7905567f94b54
Author: Steve <steve@designerwallcoverings.com>
Date: Sun Jun 21 18:01:53 2026 -0700
Add daily morning-report generator (top 10 SFV CRE opportunities -> HTML email body)
---
.gitignore | 1 +
scripts/morning-report.js | 63 +++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 64 insertions(+)
diff --git a/.gitignore b/.gitignore
index 9dc191d..a049bca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ dist/
build/
.next/
data/raw/
+data/morning-report.html
diff --git a/scripts/morning-report.js b/scripts/morning-report.js
new file mode 100644
index 0000000..fcc5dc6
--- /dev/null
+++ b/scripts/morning-report.js
@@ -0,0 +1,63 @@
+// morning-report.js — build the daily "Top 10 SFV CRE Opportunities" email body from ranked.json.
+// Prioritizes ACTIVE + affordable + verified deals by composite. Writes HTML to data/morning-report.html
+// and prints it to stdout. Pure local, no deps. The 6am job re-runs analyze.js first, then this.
+const fs = require('fs');
+const path = require('path');
+const ROOT = path.join(__dirname, '..');
+const CREFin = require('../public/finance.js');
+
+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 };
+
+// recompute at the canonical 25%-down scenario, then rank "opportunities": active + affordable first
+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 || '');
+rows.sort((a, b) => {
+ const aw = (isActive(a) ? 2 : 0) + (a.finance.affordable ? 1 : 0);
+ const bw = (isActive(b) ? 2 : 0) + (b.finance.affordable ? 1 : 0);
+ if (aw !== bw) return bw - aw;
+ return b.composite - a.composite;
+});
+const top = rows.slice(0, 10);
+
+const today = process.env.REPORT_DATE || new Date().toISOString().slice(0, 10);
+const fmt = n => n == null ? '—' : '$' + Math.round(n).toLocaleString();
+const row = (p, i) => {
+ const f = p.finance;
+ const ver = p.verified === true ? '✓ verified' : (p.cap_rate != null ? 'broker cap' : 'no income data');
+ return `<tr>
+ <td style="padding:6px 8px;font-weight:700">${i + 1}</td>
+ <td style="padding:6px 8px"><a href="${p.source}">${p.address}</a><br><span style="color:#666;font-size:12px">${p.city}, CA · ${p.type} · ${p.units}u · built ${p.year_built || '—'}</span></td>
+ <td style="padding:6px 8px;text-align:right">${fmt(p.price)}</td>
+ <td style="padding:6px 8px;text-align:right">${p.cap_rate != null ? p.cap_rate + '%' : '—'}<br><span style="font-size:11px;color:#888">${ver}</span></td>
+ <td style="padding:6px 8px;text-align:right">${f.coc != null ? f.coc + '%' : '—'}</td>
+ <td style="padding:6px 8px;text-align:right">${f.dscr ?? '—'}</td>
+ <td style="padding:6px 8px;text-align:right">${fmt(f.cashNeeded)}<br><span style="font-size:11px;color:${f.affordable ? '#178a3a' : '#b00'}">${f.affordable ? 'in budget' : 'over'}</span></td>
+ <td style="padding:6px 8px;text-align:center;font-weight:700">${p.composite}</td>
+ </tr>`;
+};
+
+const html = `<div style="font-family:-apple-system,Segoe UI,Arial,sans-serif;max-width:780px">
+<h2 style="margin:0 0 2px">SFV Commercial Real Estate — Top 10 Opportunities</h2>
+<div style="color:#666;font-size:13px">${today} · ${meta.market} · $${A.budget.toLocaleString()} down (${A.downPct}% / ${A.ratePct}% / ${A.amortYears}yr) · ${ranked.length} listings tracked</div>
+<p style="font-size:13px;color:#444;margin:12px 0">Ranked by blended score (verified in-place economics + AI thesis), active & in-budget deals first.
+Cap rates are broker/MLS-stated — verify NOI in DD. Most SFV multifamily runs negative/breakeven leverage at today's rates; positive-leverage or seller-financed deals are the standouts.</p>
+<table style="border-collapse:collapse;width:100%;font-size:13px;border:1px solid #ddd">
+<tr style="background:#f4f4f4;text-align:left">
+ <th style="padding:6px 8px">#</th><th style="padding:6px 8px">Property</th><th style="padding:6px 8px;text-align:right">Price</th>
+ <th style="padding:6px 8px;text-align:right">Cap</th><th style="padding:6px 8px;text-align:right">CoC</th>
+ <th style="padding:6px 8px;text-align:right">DSCR</th><th style="padding:6px 8px;text-align:right">Cash needed</th><th style="padding:6px 8px;text-align:center">Score</th>
+</tr>
+${top.map(row).join('\n')}
+</table>
+<h3 style="margin:16px 0 6px;font-size:14px">#1 thesis</h3>
+<p style="font-size:13px;color:#333;margin:0">${top[0] ? `<b>${top[0].address}, ${top[0].city}</b> — ${top[0].qwen?.thesis || ''}` : ''}</p>
+<p style="color:#888;font-size:12px;margin-top:16px">Live viewer: http://127.0.0.1:9911 · generated by commercialrealestate/scripts/morning-report.js</p>
+</div>`;
+
+fs.writeFileSync(path.join(ROOT, 'data', 'morning-report.html'), html);
+process.stdout.write(html);
← 5b97a77 Tighten live-comps units parse
·
back to Commercialrealestate
·
Daily 6am top-10 report -> steve-office via George (launchd) c09a426 →