Occupancy & Revenue Pulse Reporter
What you'll learn
~50 min- Standardize messy PMS exports into a unified KPI data model
- Calculate week-over-week and month-to-date trend deltas automatically
- Generate executive narrative text from metrics (AI-assisted 'risks + actions' section)
- Package owner-ready HTML and PDF report output
What you’re building
Every Friday afternoon, Nick sits down to build the weekly report for his regional manager. He pulls a CSV from Yardi, opens Excel, manually calculates occupancy percentage, eyeballs which direction the numbers moved since last week, types up a paragraph about risks and actions, pastes in some charts, and emails the whole thing as a PDF. The process takes about 90 minutes. It is the same 90 minutes every single week, and the format never changes.
You are going to build a CLI tool that does the entire thing in one command. Feed it the Yardi export CSV, and it produces a polished HTML report with KPI cards, trend arrows, interactive charts, and an auto-generated “Risks & Actions” narrative — plus a PDF version ready to email. The report covers the metrics that matter at a 413-unit luxury community: occupancy percentage, pre-lease percentage, renewal rate, delinquency, concessions, and leasing velocity. Week-over-week and month-to-date deltas are calculated automatically so Nick can see at a glance whether the building is trending up or down.
Nick spends 90 minutes building this report manually. That is 78 hours a year — almost two full work weeks — copying numbers between systems and formatting cells. This tool gives him those hours back. More importantly, it eliminates the transcription errors that happen when you are manually pulling numbers at 4 PM on a Friday. The report is only as good as the data, and a machine does not fat-finger a decimal point.
Read raw tabular data, compute derived metrics and trend deltas, generate human-readable narrative from the numbers, and export to both web (HTML) and print (PDF) formats. This pattern appears in financial reporting, healthcare quality dashboards, logistics scorecards — anywhere a human needs to present metrics to a stakeholder on a recurring schedule.
🔍Property management primer: the numbers that run a building
If you have never worked in property management, here is what these KPIs mean and why they matter:
- Yardi is property management software (PMS). It tracks leases, rent payments, maintenance, and accounting. Most large management companies use Yardi, RealPage, or Entrata. Yardi is the 800-pound gorilla. It exports data as CSVs, and those exports are the raw material for every report a property manager produces.
- Occupancy % is the number of occupied units divided by total units. At the Concord (413 units), if 397 are occupied, occupancy is 96.1%. The target for a stabilized luxury property is 94-97%. Below 94% means revenue is leaking. Above 97% means you might be pricing too low.
- Pre-lease % is the percentage of units that are leased for a future date. If 8 units are vacant but 5 already have signed leases starting next month, your pre-lease rate is high and the vacancy is temporary. Pre-lease percentage tells you whether today’s vacancy is a problem or just a timing gap.
- Leasing velocity is how many new leases you sign per week (or per month). At 413 units with 12-month leases, roughly 34 leases expire per month. If leasing velocity matches or exceeds expirations, the building stays full. If velocity drops, vacancy rises a month or two later. It is a leading indicator.
- Concessions are discounts offered to attract new residents — one month free, reduced rent for the first 3 months, waived application fees. They are a tool for filling units, but they reduce effective rent. A spike in concessions means the market is softening or the property is struggling to compete.
- Delinquency is unpaid rent as a percentage of total rent due. At a luxury property like the Concord, delinquency should be under 2%. Higher than that signals collection problems, economic stress in the resident base, or both.
- Renewal rate is the percentage of expiring leases where the resident signs a new lease instead of moving out. High renewal rates (above 55-60%) reduce turnover costs and maintain occupancy. Each turnover costs $3,000-5,000 in make-ready, vacancy loss, and leasing costs.
Who this is for
Nick is the General Manager at the Concord Crystal City, a 413-unit, 18-story Bozzuto-managed luxury apartment community in Arlington, VA. He reports to a Regional Vice President who oversees six properties. Every Friday, the RVP expects a one-page pulse report from each GM. Nick has never written a line of code. He uses Excel daily, Yardi constantly, and his phone for everything else. He is practical: if a tool saves him time, he will use it. If it requires 30 minutes of setup, he will not.
The showcase
The finished tool produces:
- KPI cards across the top: occupancy %, pre-lease %, renewal rate, delinquency %, average concession, leasing velocity (leases/week). Each card shows the current value, a trend arrow (up/down/flat), and the week-over-week delta.
- WoW and MTD trend section: a table showing this week vs. last week vs. month-to-date for every KPI, with green/red color coding for favorable/unfavorable movement.
- Interactive Chart.js visualizations: occupancy trend line (last 12 weeks), leasing velocity bar chart (last 8 weeks), delinquency waterfall, and concession spend over time.
- Auto-generated “Risks & Actions” narrative: three to five sentences summarizing what moved, what is at risk, and what actions Nick should highlight. Written in the tone of a property manager briefing a regional, not a robot reciting numbers.
- HTML report: opens in any browser, dark professional styling with the Concord’s teal accent color, print-friendly layout.
- PDF export: generated from the HTML using Puppeteer, ready to attach to an email.
- Sample Yardi-format data: a realistic CSV so you can test immediately without access to a real Yardi system.
The prompt
Start your AI CLI tool in an empty directory and paste this prompt:
Build a Node.js CLI tool called pulse-reporter that reads a property managementCSV export and generates a weekly KPI report with trend analysis, interactivecharts, and an auto-generated narrative summary. The output is both an HTMLreport and a PDF.
PROJECT STRUCTURE:pulse-reporter/ package.json src/ cli.js (entry point, argument parsing) data-loader.js (CSV parser with Yardi format handling) kpi-engine.js (KPI calculations, trend deltas, thresholds) narrative.js (auto-generated risks & actions text) chart-builder.js (Chart.js configuration generators) report-builder.js (HTML report assembly using Handlebars) pdf-export.js (Puppeteer-based HTML-to-PDF conversion) sample-data.js (generates realistic Yardi-format test CSV) templates/ report.hbs (Handlebars HTML report template) static/ style.css (report styling)
REQUIREMENTS:
1. CLI INTERFACE (src/cli.js) - Usage: node src/cli.js [options] <csv-file> - --output or -o: output directory for report files (default: ./pulse-report) - --week-ending or -w: report week-ending date in YYYY-MM-DD format (default: most recent Friday) - --property or -p: property name override (default: "Concord Crystal City") - --units or -u: total unit count (default: 413) - --pdf: also generate a PDF version (requires Puppeteer) - --generate-sample: generate sample CSV data and exit - --previous or -prev: path to previous week's CSV for trend comparison (if not provided, trends show "N/A" instead of deltas)
2. DATA LOADER (src/data-loader.js) Parse a Yardi-format CSV export. Yardi exports vary by report, but the tool should handle this common format:
Expected columns (case-insensitive, flexible matching): - Unit Number (or Unit, Unit #, UnitID) - Unit Type (or FloorPlan, Floor Plan, Type) - Sq Ft (or SqFt, Square Feet, Area) - Market Rent (or MarketRent, Asking Rent) - Actual Rent (or CurrentRent, Lease Rent, Charge) - Status (or Unit Status, Occ Status) Values: "Occupied", "Vacant", "Vacant-Leased", "Notice", "Down/Model" - Lease Start (or LeaseStart, Move-In, MoveIn) - Lease End (or LeaseEnd, LeaseExpiration, Expiration) - Resident Name (or Resident, Tenant, Name) -- optional - Move-In Date (or MoveInDate, Actual Move-In) - Move-Out Date (or MoveOutDate, Actual Move-Out) -- blank if still occupied - Concession (or Concession Amount, MonthlyConc) -- dollar amount, 0 if none - Balance (or Balance Due, Delinquent Amount, AR Balance) -- current unpaid balance - Renewal Status (or RenewalStatus) -- "Renewed", "MTM", "Pending", "NoticeGiven", blank
Auto-detect column names by fuzzy matching headers. Strip dollar signs, commas, and whitespace from numeric fields. Parse dates flexibly (MM/DD/YYYY, YYYY-MM-DD, M/D/YY). Log a warning for any unrecognized columns. Fail with a clear error if critical columns (Unit Number, Status, Market Rent) are missing.
3. KPI ENGINE (src/kpi-engine.js) Calculate these KPIs from the parsed data:
a. OCCUPANCY - Physical Occupancy %: occupied units / total leasable units (exclude Down/Model units from denominator) - Economic Occupancy %: actual collected rent / potential gross rent - Vacant unit count and list - Vacancy cost: sum of market rent for all vacant-unrented units
b. PRE-LEASE - Pre-lease %: (occupied + vacant-leased) / total leasable units - Units currently "Vacant-Leased" (signed lease but not yet moved in) - Expected move-in dates for pre-leased units
c. LEASING VELOCITY - New leases signed this week: count of units where Lease Start falls within the report week (week-ending date minus 6 days to week-ending date) - Leasing velocity: new leases / 7 days (as a daily rate) - Trailing 4-week average for comparison
d. RENEWAL RATE - Leases expiring this month: count where Lease End is in the report month - Renewed: count with Renewal Status = "Renewed" - MTM (month-to-month): count with Renewal Status = "MTM" - Notice given: count with Renewal Status = "NoticeGiven" - Renewal rate %: renewed / (renewed + noticeGiven) (exclude MTM and pending from the calculation)
e. DELINQUENCY - Total delinquent balance: sum of all positive Balance values - Delinquency %: total delinquent / total monthly rent roll - Count of delinquent units (balance > $0) - Top 5 delinquent accounts (unit number and balance, no names in report)
f. CONCESSIONS - Total monthly concession spend: sum of Concession column - Average concession per concessed unit - Concession as % of gross potential rent - Count of units receiving concessions
TREND CALCULATION: If a previous week's CSV is provided, calculate: - Week-over-week delta for each KPI (current - previous) - Direction: "up", "down", or "flat" (within 0.1% tolerance) - Color coding: green if favorable (occupancy up, delinquency down), red if unfavorable
THRESHOLDS (flag KPIs outside healthy ranges): - Occupancy < 94%: flag red - Delinquency > 2%: flag red - Renewal rate < 50%: flag yellow - Concessions > 3% of GPR: flag yellow - Leasing velocity < 5 leases/week: flag yellow
4. NARRATIVE GENERATOR (src/narrative.js) Generate a 3-5 sentence "Risks & Actions" paragraph from the KPI results. Use template-based generation (not an LLM call -- this runs offline):
Rules: - If occupancy dropped WoW: "Occupancy declined [X]pp to [Y]%, driven by [N] move-outs against [M] move-ins this week." - If delinquency exceeds 2%: "Delinquency stands at [X]% ($[Y] outstanding across [N] units). Collections follow-up is the priority this week." - If renewal rate is below 55%: "Renewal conversion is tracking at [X]%. [N] leases expire next month -- outreach to undecided residents is recommended." - If leasing velocity is strong: "Leasing velocity remains healthy at [X] leases/week, [Y]% above the trailing 4-week average." - If concessions are rising: "Concession spend increased to $[X] this month ([Y]% of GPR). Monitor market comps to confirm pricing position." - Always close with one forward-looking action statement. - Tone: professional but direct. Written as a GM briefing a regional VP -- not a chatbot summary.
5. CHART BUILDER (src/chart-builder.js) Generate Chart.js configuration objects (rendered client-side in the HTML):
a. Occupancy Trend Line: 12-week x-axis (use placeholder labels if only 1 week of data), occupancy % on y-axis, target line at 95%. Dark theme: line color #0F766E (teal), grid #1e1e2a, background #111118.
b. Leasing Velocity Bar Chart: 8-week bars showing leases signed per week. Color: #0F766E for bars, #374151 for grid.
c. Delinquency Breakdown: horizontal bar showing current-month delinquency by aging bucket (0-30 days, 31-60, 61-90, 90+).
d. Concession Trend: area chart showing concession spend over the last 8 weeks as a percentage of GPR.
Each chart config should be a JSON object that the HTML template injects into a <script> tag and renders with Chart.js loaded via CDN.
6. REPORT BUILDER (src/report-builder.js) Assemble the HTML report using Handlebars:
Layout: - Header: property name, management company ("Bozzuto Management"), report period ("Week Ending March 7, 2026"), generated timestamp - KPI Card Row: 6 cards in a responsive grid, each showing: metric name, value (large), trend arrow (unicode or SVG), WoW delta, status indicator (green/yellow/red dot based on thresholds) - Trend Table: all KPIs in rows, columns for This Week, Last Week, WoW Delta, MTD, status - Charts Section: 2x2 grid of Chart.js canvases - Risks & Actions: the auto-generated narrative in a highlighted card - Unit Detail Appendix (collapsible): table of all vacant and notice units with unit number, type, market rent, status, and days vacant - Footer: "Generated by Pulse Reporter | Data source: Yardi export"
STYLING (static/style.css): - Dark professional theme: background #09090b, cards #141414, borders #1e1e2a, text #e5e5e5 - Accent color: #0F766E (Concord teal) - KPI cards with subtle gradient borders - Trend arrows: green (#10b981) for favorable, red (#ef4444) for unfavorable, gray (#6b7280) for flat - Print CSS: white background, black text, no interactive elements - Responsive: stacks to single column on mobile
7. PDF EXPORT (src/pdf-export.js) Use Puppeteer to convert the HTML report to PDF: - Launch headless Chrome - Load the generated HTML file - Wait for Chart.js to render (wait for canvas elements) - Export as Letter-size PDF with 0.5-inch margins - Filename: pulse-report-YYYY-MM-DD.pdf
8. SAMPLE DATA GENERATOR (src/sample-data.js) When --generate-sample is passed, create two CSV files: - sample-current-week.csv: 413 units for the Concord Crystal City with realistic data: - 397 occupied, 10 vacant, 4 vacant-leased, 2 down/model - Mix of unit types: Studio (45), 1BR (180), 2BR (150), 3BR (38) - Market rents: Studio $1,850-2,100, 1BR $2,200-2,800, 2BR $2,900-3,600, 3BR $3,800-4,500 - 8 units with concessions ($200-500/month) - 12 units with delinquent balances ($500-4,200) - 6 new leases signed this week (Lease Start in current week) - 15 leases expiring this month: 9 renewed, 2 MTM, 2 notice, 2 pending - sample-previous-week.csv: same property one week earlier with slightly different numbers (398 occupied, 9 vacant, occupancy 0.4% higher, delinquency 0.2% lower) to demonstrate trend calculation
Use realistic Yardi column headers: "Unit Number", "Floor Plan", "Sq Ft", "Market Rent", "Actual Rent", "Unit Status", "Lease Start", "Lease End", "Concession", "Balance Due", "Renewal Status"
DEPENDENCIES: csv-parser, handlebars, puppeteer, chalk, commanderThe --pdf flag uses Puppeteer, which downloads a Chromium binary (~300 MB). If you just want the HTML report, skip the --pdf flag. The HTML report is fully self-contained and looks just as good printed from a browser via Ctrl+P.
What you get
After your AI CLI tool finishes, set up the project:
cd pulse-reporternpm installGenerate sample data and run the report
# Generate realistic Yardi-format test CSVsnode src/cli.js --generate-sample -o ./test-report
# Run the report with both current and previous week datanode src/cli.js ./test-report/sample-current-week.csv \ --previous ./test-report/sample-previous-week.csv \ --week-ending 2026-03-06 \ -o ./test-report
# Also generate a PDF (requires Puppeteer)node src/cli.js ./test-report/sample-current-week.csv \ --previous ./test-report/sample-previous-week.csv \ --week-ending 2026-03-06 \ -o ./test-report \ --pdfOpen ./test-report/pulse-report.html in your browser. You should see:
- Six KPI cards across the top with occupancy around 96.1%, pre-lease around 97.1%, renewal rate around 81.8%, delinquency around 1.4%, average concession around $340, and leasing velocity around 6 leases/week.
- Trend arrows: occupancy should show a red down arrow (dropped from previous week), delinquency should show a red up arrow (increased from previous week), and leasing velocity should be green or flat.
- Four charts: occupancy trend line (only two data points with sample data, but the chart frame is there), leasing velocity bars, delinquency breakdown by aging, and concession trend.
- Risks & Actions narrative: something like “Occupancy declined 0.4pp to 96.1%, driven by 3 move-outs against 2 move-ins this week. Delinquency ticked up to 1.4% — within threshold but trending unfavorably. Leasing velocity remains healthy at 6 leases/week. Three vacant-unrented units represent $8,400/month in vacancy loss. Priority this week: convert the 2 pending renewals before month-end.”
- Vacant unit appendix (collapsed by default): a table listing all vacant and notice units with details.
Common issues and fixes
| Problem | Follow-up prompt |
|---|---|
| CSV parsing fails on Yardi export | The CSV parser is failing on my Yardi export. Yardi sometimes includes a header row with the report title before the actual column headers. Add logic to skip non-data rows at the top: scan the first 10 lines and find the row that contains "Unit" or "Unit Number" as a header -- that is the real header row. Skip everything above it. |
| KPI cards show NaN | Several KPI values show NaN. This happens when the Status column has unexpected values like "Occupied-NTV" or "Vacant-Rented" that the parser does not recognize. Map all status variants to the canonical set: anything starting with "Occupied" maps to "Occupied", "Vacant-Leased" or "Vacant-Rented" maps to "Vacant-Leased", "Vacant" stays "Vacant", "Down" or "Model" or "Down/Model" maps to "Down/Model". |
| Trend arrows all show N/A even with previous file | The trend calculation is returning N/A for everything even though I passed --previous. Check that the data-loader correctly parses both files and that the KPI engine receives both parsed datasets. The previous file might have a slightly different column order or header spelling -- the fuzzy column matcher should handle both files identically. |
| Charts do not render in PDF | The PDF shows blank rectangles where charts should be. Puppeteer is capturing the page before Chart.js finishes rendering. In pdf-export.js, after loading the HTML, add a waitForFunction that checks whether all canvas elements have been drawn: await page.waitForFunction('document.querySelectorAll("canvas").length > 0 && typeof Chart !== "undefined"'). Then add a 1-second delay to let animations complete before capturing. |
When Things Go Wrong
Use the Symptom → Evidence → Request pattern: describe what you see, paste the error, then ask for a fix.
How it works
The tool follows a clean data pipeline:
-
CLI (
cli.js) parses arguments with Commander, validates that the CSV file exists, and orchestrates the pipeline stages: load data, calculate KPIs, generate narrative, build charts, assemble report, optionally export PDF. -
Data Loader (
data-loader.js) reads the Yardi CSV with flexible column matching. It normalizes column names, parses dates and currency values, maps status variants to canonical values, and returns a structured array of unit records. It also loads the previous week’s data if provided. -
KPI Engine (
kpi-engine.js) takes the parsed unit records and calculates every metric. It groups units by status, sums rent and balance fields, counts leases by date range, and computes percentages. If previous-week data is available, it runs the same calculations on that data and computes deltas. It returns a structured KPI results object with current values, previous values, deltas, directions, and threshold flags. -
Narrative Generator (
narrative.js) takes the KPI results and applies template rules to produce a paragraph. It checks thresholds, identifies the most significant movements, and assembles sentences in priority order. The output reads like a human wrote it because the templates are written in a human voice — the logic just selects which templates to use and fills in the numbers. -
Chart Builder (
chart-builder.js) generates Chart.js configuration objects as JSON. The HTML template embeds these in<script>tags so the charts render client-side when the report is opened in a browser. Each chart is configured with the dark theme colors and responsive sizing. -
Report Builder (
report-builder.js) compiles the Handlebars template with all the data: KPI results, narrative, chart configs, property metadata, and the vacant unit list. It writes the HTML file and copies the CSS. -
PDF Export (
pdf-export.js) launches Puppeteer, loads the HTML report, waits for charts to render, and exports to PDF with print-friendly CSS applied.
Customize it
Add email distribution
Add a --email flag that accepts one or more email addresses. After generatingthe report, send it as an email with the HTML report as the email body and thePDF as an attachment. Use nodemailer with SMTP configuration from environmentvariables (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS). Set the subject to"Concord Crystal City -- Weekly Pulse Report -- Week Ending [date]".Set the From to "Concord Property Ops <reports@concordcrystalcity.com>".Add historical trend storage
Add a --save-history flag that stores each week's KPI results as a JSON filein a history/ directory (one file per week, named kpi-YYYY-MM-DD.json).When generating a report, automatically load all history files and use themto populate the 12-week occupancy trend chart and 8-week velocity chart withreal data instead of placeholders. This way the charts get richer every weekas Nick runs the tool.Add comp set comparison
Add a --comp flag that accepts a CSV file with competitor property data(property name, unit count, occupancy %, average rent). Add a "Market Position"section to the report showing the Concord's metrics versus the comp set average.Show a bar chart comparing occupancy and average rent across properties.Highlight where the Concord is above or below the comp set average.Add Yardi API connector
Add a --yardi-api flag that pulls data directly from the Yardi API insteadof a CSV file. Accept the Yardi server URL, database name, and API key fromenvironment variables (YARDI_URL, YARDI_DB, YARDI_KEY). Call the YardiResidentData and UnitData API endpoints, transform the JSON response intothe same format the CSV parser produces, and feed it into the existingpipeline. This eliminates the CSV export step entirely.Try it yourself
- Generate the pulse reporter with the prompt above.
- Run
npm installand generate sample data with--generate-sample. - Run the report with both current and previous week CSVs.
- Open the HTML report. Check every KPI card — do the numbers make sense for a 413-unit property?
- Read the Risks & Actions narrative. Does it sound like something a property manager would write?
- Collapse and expand the vacant unit appendix. Is the detail useful?
- If you have Puppeteer installed, generate the PDF with
--pdfand verify it looks clean when printed. - Change a value in the sample CSV (set 10 more units to “Vacant”) and re-run. Does the report reflect the change?
Key takeaways
- Automated reporting eliminates transcription errors and saves hours every week. The data goes from Yardi to finished report without a human copying numbers between systems. The report is reproducible — run it twice with the same data, get the same result.
- Trend deltas are more valuable than point-in-time snapshots. Occupancy at 96% is fine. Occupancy at 96% and dropping 0.4% per week for three weeks is a problem. The week-over-week calculation surfaces the direction of the business, not just the position.
- Template-based narrative generation works for recurring reports. You do not need an LLM to write a weekly summary. A set of well-written conditional templates that select sentences based on KPI thresholds produces professional, consistent output. It is deterministic, fast, and works offline.
- Dual-format export (HTML + PDF) covers both audiences. The regional VP wants a PDF attachment. Nick wants an interactive version he can drill into. Same data, two outputs, one command.
- Flexible column matching makes the tool resilient to Yardi export variations. Different Yardi reports use different column names. Fuzzy matching on headers means the tool works with whatever Nick exports, not just one specific report format.
The Concord has 413 total units. 2 are down as models. Of the remaining 411 leasable units, 395 are occupied, 10 are vacant, 4 are vacant-leased, and 2 are on notice. What is the physical occupancy percentage?
What’s next
In Lesson 6, you will build the Daily Ops Command Center — a unified daily dashboard that integrates the resident communications tool, maintenance triage board, renewal planner, vendor orchestrator, and this pulse reporter into a single operating hub with a “Morning Run” one-click automation. It is the capstone that ties every tool together into Nick’s daily workflow.