<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Vamshi in Data]]></title><description><![CDATA[Vamshi in Data]]></description><link>https://vamshiindata.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 18 Sep 2026 15:18:35 GMT</lastBuildDate><atom:link href="https://vamshiindata.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[I Built a No-Code Email Automation Tool in Pure HTML — Here's How It Works]]></title><description><![CDATA[Send personalized bulk emails through Outlook without touching SMTP, credentials, or any third-party API. One HTML file. One Python script. Done.


The Problem
Every few weeks, my team needs to send p]]></description><link>https://vamshiindata.hashnode.dev/i-built-a-no-code-email-automation-tool-in-pure-html-here-s-how-it-works</link><guid isPermaLink="true">https://vamshiindata.hashnode.dev/i-built-a-no-code-email-automation-tool-in-pure-html-here-s-how-it-works</guid><category><![CDATA[Python]]></category><category><![CDATA[automation]]></category><category><![CDATA[excel]]></category><category><![CDATA[outlook]]></category><category><![CDATA[Productivity]]></category><category><![CDATA[No Code]]></category><dc:creator><![CDATA[Vamshi Yempally]]></dc:creator><pubDate>Sat, 27 Jun 2026 12:15:47 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>Send personalized bulk emails through Outlook without touching SMTP, credentials, or any third-party API. One HTML file. One Python script. Done.</p>
</blockquote>
<hr />
<h2>The Problem</h2>
<p>Every few weeks, my team needs to send personalized reports to dozens of recipients — coders, clients, ops contacts. The data lives in Excel. The emails need to be personalized per row. And IT won't let us touch external SMTP relays.</p>
<p>PowerShell scripts felt fragile. Outlook mail-merge is clunky. Paid tools require subscriptions and data sharing agreements nobody wants to sign.</p>
<p>So I built my own: a single-file HTML tool that reads an Excel/CSV, lets you map columns, compose a templated email, and then generates a ready-to-run Python script that drives <strong>Classic Outlook via COM automation</strong> — no credentials, no API keys, no third-party services.</p>
<hr />
<h2>What It Does</h2>
<p>The tool walks you through <strong>5 steps</strong>:</p>
<ol>
<li><p><strong>Upload</strong> your Excel or CSV file (<code>.xlsx</code>, <code>.xls</code>, <code>.csv</code>)</p>
</li>
<li><p><strong>Set the header row</strong> — click any row in the preview to mark it</p>
</li>
<li><p><strong>Map columns</strong> — pick which column is "To", which are "CC", "BCC", and which data columns to include</p>
</li>
<li><p><strong>Compose</strong> the email — subject, body, signature, with <code>{ColumnName}</code> placeholder support</p>
</li>
<li><p><strong>Preview &amp; Send</strong> — download a Python script, run it, and Outlook does the rest</p>
</li>
</ol>
<hr />
<h2>The Tech Stack (Surprisingly Minimal)</h2>
<table>
<thead>
<tr>
<th>Layer</th>
<th>What I Used</th>
</tr>
</thead>
<tbody><tr>
<td>UI</td>
<td>Vanilla HTML + CSS + JavaScript</td>
</tr>
<tr>
<td>Excel parsing</td>
<td><a href="https://sheetjs.com/">SheetJS (<code>xlsx</code>)</a> via CDN</td>
</tr>
<tr>
<td>Email delivery</td>
<td>Python + <code>pywin32</code> (COM automation)</td>
</tr>
<tr>
<td>Attachments</td>
<td><code>openpyxl</code> for Excel, built-in for CSV/JSON</td>
</tr>
<tr>
<td>Zero dependencies at runtime</td>
<td>✅ (except the Python packages)</td>
</tr>
</tbody></table>
<p>The entire frontend is <strong>one self-contained HTML file</strong>. No build step, no bundler, no React. It runs locally in any browser.</p>
<hr />
<h2>The Core Idea: Generate, Don't Send</h2>
<p>The browser <strong>cannot</strong> send emails directly — and honestly, you wouldn't want it to. Instead, the tool generates a Python script that:</p>
<ol>
<li><p>Decodes a base64-encoded JSON blob embedded inside it (containing all email data)</p>
</li>
<li><p>Connects to Classic Outlook via <code>win32com.client</code></p>
</li>
<li><p>Loops through each email item, builds the message, optionally attaches files, and either <code>.Display()</code>s it for review or <code>.Send()</code>s it automatically</p>
</li>
</ol>
<pre><code class="language-python">ol = win32com.client.Dispatch("Outlook.Application")
mail = ol.CreateItem(0)   # 0 = olMailItem
mail.To = itm['to']
mail.Subject = itm['subj']
mail.HTMLBody = itm['html']
mail.Attachments.Add(tmp_path)
mail.Display(False)   # or mail.Send()
</code></pre>
<p>This means <strong>no SMTP credentials</strong>, no OAuth tokens, no app passwords. Outlook is already signed in — we just drive it programmatically.</p>
<hr />
<h2>Template Variables</h2>
<p>One of my favorite features: <code>{ColumnName}</code> placeholders anywhere in the subject or body.</p>
<p>If your spreadsheet has columns like <code>FirstName</code>, <code>Department</code>, <code>ReportMonth</code>, you can write:</p>
<pre><code class="language-plaintext">Subject: Your {ReportMonth} Report, {FirstName}

Body:
Dear {FirstName},

Please find your {Department} performance summary below.
</code></pre>
<p>Each email is personalized per row — zero manual copy-paste.</p>
<p>The replacement logic is dead simple:</p>
<pre><code class="language-javascript">function tmpl(tpl, row) {
  return tpl.replace(/\{([^}]+)\}/g, (_, key) =&gt; {
    const idx = headers.findIndex(h =&gt; h.toLowerCase() === key.toLowerCase());
    return idx &gt;= 0 ? String(row[idx] ?? '') : '{' + key + '}';
  });
}
</code></pre>
<p>Case-insensitive matching so <code>{firstName}</code> and <code>{FirstName}</code> both work.</p>
<hr />
<h2>Handling Duplicate Recipients</h2>
<p>When the same email address appears in multiple rows, the tool detects it and asks you how to handle it:</p>
<ul>
<li><p><strong>Group by recipient</strong> — combine all their rows into one email with a multi-row table</p>
</li>
<li><p><strong>Send separate</strong> — one email per row, even if the address repeats</p>
</li>
</ul>
<p>This was a real pain point in older scripts. A finance analyst might appear 3 times in a report (different projects) — do they get 3 emails or 1? Now the user decides.</p>
<hr />
<h2>Attachment Formats</h2>
<p>The tool supports 6 delivery formats:</p>
<table>
<thead>
<tr>
<th>Format</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td>Body Table</td>
<td>HTML table embedded in the email body</td>
</tr>
<tr>
<td>Excel Attachment</td>
<td><code>.xlsx</code> with formatted headers and alternating row shading</td>
</tr>
<tr>
<td>CSV Attachment</td>
<td>Plain <code>.csv</code> file</td>
</tr>
<tr>
<td>JSON Attachment</td>
<td>Structured <code>.json</code> for downstream systems</td>
</tr>
<tr>
<td>Body + Excel</td>
<td>Table in body <em>and</em> Excel file attached</td>
</tr>
<tr>
<td>Body + CSV</td>
<td>Table in body <em>and</em> CSV file attached</td>
</tr>
</tbody></table>
<p>For Excel attachments, <code>openpyxl</code> handles the formatting — navy headers, alternating row fills, auto-fitted column widths (capped at 50 chars).</p>
<hr />
<h2>Two Send Modes</h2>
<p>Before running the script, you pick a mode:</p>
<p><strong>Display in Outlook</strong> — opens each compose window for your review. You click Send yourself. Great for the first run.</p>
<p><strong>Auto-Send</strong> — fires everything automatically. Use this after you've verified the output with a test email.</p>
<p>There's also a <strong>Test Email</strong> button that generates a separate <code>test_email.py</code> script using only the first data row, sent to a test address of your choice.</p>
<hr />
<h2>Setup (One-Time)</h2>
<pre><code class="language-bash">pip install pywin32 openpyxl
python -m pywin32_postinstall -install
</code></pre>
<p>That's it. After that, every use is:</p>
<ol>
<li><p>Open the HTML file in your browser</p>
</li>
<li><p>Upload your spreadsheet</p>
</li>
<li><p>Configure and compose</p>
</li>
<li><p>Download the <code>.py</code> script</p>
</li>
<li><p>Run <code>python send_emails.py</code></p>
</li>
</ol>
<hr />
<h2>What I'd Add Next</h2>
<ul>
<li><p><strong>Sheet selector</strong> — currently uses the first sheet; a dropdown for multi-sheet workbooks would help</p>
</li>
<li><p><strong>Scheduling</strong> — integrate with Windows Task Scheduler or a simple delay mechanism</p>
</li>
<li><p><strong>Preview pane</strong> — render a live HTML preview of the generated email inside the browser before downloading</p>
</li>
<li><p><strong>Progress UI</strong> — the Python script logs to console; a GUI progress bar (even a simple <code>tkinter</code> one) would be nicer for non-technical users</p>
</li>
</ul>
<hr />
<h2>Final Thoughts</h2>
<p>This tool started as a weekend script and became something my whole operations team now uses. The key insight was: <strong>don't fight the constraints</strong>. Classic Outlook is already there, already authenticated, already trusted by IT. COM automation is old but rock-solid. Lean into it.</p>
<p>The entire UI in one HTML file means zero deployment headaches. Drop it on a shared drive and anyone on Windows with Python installed can use it.</p>
<p>If you're in ops, reporting, or any role that sends personalized bulk emails from spreadsheet data, give it a try.</p>
<hr />
<p><em>Built with SheetJS, pywin32, and openpyxl. Runs on Windows with Classic Outlook.</em></p>
<p><em>Tags:</em> <code>python</code> <code>automation</code> <code>excel</code> <code>outlook</code> <code>productivity</code> <code>no-code</code></p>
]]></content:encoded></item><item><title><![CDATA[Building a Daily Production Dashboard in Power BI for a Medical Coding Team]]></title><description><![CDATA[This post walks through how I built the daily production dashboard in Power BI, the DAX measures that power it, and the lessons learned along the way.

What the Dashboard Needed to Show
Before touchin]]></description><link>https://vamshiindata.hashnode.dev/building-a-daily-production-dashboard-in-power-bi-for-a-medical-coding-team</link><guid isPermaLink="true">https://vamshiindata.hashnode.dev/building-a-daily-production-dashboard-in-power-bi-for-a-medical-coding-team</guid><dc:creator><![CDATA[Vamshi Yempally]]></dc:creator><pubDate>Sat, 27 Jun 2026 12:06:35 GMT</pubDate><content:encoded><![CDATA[<p>This post walks through how I built the daily production dashboard in Power BI, the DAX measures that power it, and the lessons learned along the way.</p>
<hr />
<h2>What the Dashboard Needed to Show</h2>
<p>Before touching Power BI, I listed out exactly what the team needed to see each day:</p>
<ol>
<li><p><strong>Each coder's CPH for the day</strong> — compared against their individual target</p>
</li>
<li><p><strong>Which coders are above, at, or below target</strong> — at a glance, no scrolling</p>
</li>
<li><p><strong>Total team productivity</strong> — rolled up to a single number for management</p>
</li>
<li><p><strong>Client-level breakdown</strong> — which clients are producing well, which are lagging</p>
</li>
<li><p><strong>Trend over the past 30 days</strong> — to separate a bad day from a bad pattern</p>
</li>
</ol>
<p>Every visual and every measure I built traces back to one of those five needs.</p>
<hr />
<h2>Data Sources</h2>
<p>The dashboard pulls from three main sources:</p>
<table>
<thead>
<tr>
<th>Source</th>
<th>What It Contains</th>
</tr>
</thead>
<tbody><tr>
<td>Production extract</td>
<td>Daily coding records — coder, client, codes/charts completed, hours logged</td>
</tr>
<tr>
<td>Coder master</td>
<td>Coder name, level (L1/L2/L3), assigned client, hire date</td>
</tr>
<tr>
<td>Target lookup table</td>
<td>CPH floor and target per client + coder level + claim type</td>
</tr>
</tbody></table>
<p>The <strong>target lookup table</strong> is the most critical piece. Without it, you can show activity but you cannot show performance. I maintain this as a separate table in the data model with defined relationships.</p>
<hr />
<h2>Data Model Structure</h2>
<p>The model follows a simple star schema:</p>
<pre><code class="language-plaintext">FactProduction (daily grain)
    → DimCoder (one row per coder)
    → DimClient (one row per client)
    → DimDate (standard date table)
    → DimTargetLookup (CPH targets by client + level + claim type)
</code></pre>
<p>One important design decision: <strong>DimTargetLookup connects to FactProduction through a bridge</strong> using both Client and Coder Level as match keys — not a single foreign key. This required a calculated column in the fact table to create a composite key for the relationship.</p>
<hr />
<h2>Key DAX Measures</h2>
<p>Here are the core measures that drive the dashboard.</p>
<h3>1. Actual CPH</h3>
<pre><code class="language-dax">Actual CPH = 
DIVIDE(
    SUM(FactProduction[CodesCompleted]),
    SUM(FactProduction[ProductiveHours]),
    0
)
</code></pre>
<p>Simple — but note we use <strong>ProductiveHours</strong>, not total shift hours. This excludes training time, system downtime, and breaks.</p>
<h3>2. Chart Target CPH (Range-Based Lookup)</h3>
<p>This is the most complex measure. It needs to find the right target from the lookup table based on the current filter context (which client, which coder level):</p>
<pre><code class="language-dax">Chart Target CPH = 
CALCULATE(
    AVERAGE(DimTargetLookup[TargetCPH]),
    TREATAS(
        VALUES(DimCoder[CoderLevel]),
        DimTargetLookup[CoderLevel]
    ),
    TREATAS(
        VALUES(DimClient[ClientName]),
        DimTargetLookup[ClientName]
    )
)
</code></pre>
<blockquote>
<p><strong>Watch out:</strong> If your client names in <code>DimClient</code> don't exactly match those in <code>DimTargetLookup</code>, this measure returns blank — silently. Always audit your client name lists before publishing the report.</p>
</blockquote>
<h3>3. CPH vs Target (Variance)</h3>
<pre><code class="language-dax">CPH Variance = [Actual CPH] - [Chart Target CPH]
</code></pre>
<h3>4. Performance Status</h3>
<p>This drives the conditional formatting — green, amber, or red for each coder row:</p>
<pre><code class="language-dax">Performance Status = 
VAR ActualCPH = [Actual CPH]
VAR TargetCPH = [Chart Target CPH]
VAR FloorCPH  = [Chart Floor CPH]
RETURN
    SWITCH(
        TRUE(),
        ISBLANK(TargetCPH),        "No Target",
        ActualCPH &gt;= TargetCPH,    "On Target",
        ActualCPH &gt;= FloorCPH,     "Below Target",
        "Below Floor"
    )
</code></pre>
<p>This gives you three meaningful states — not just a pass/fail binary.</p>
<h3>5. Weighted Team CPH</h3>
<p>Rolling up individual CPH to a team total requires weighting by hours, not a simple average:</p>
<pre><code class="language-dax">Weighted Team CPH = 
DIVIDE(
    SUMX(
        VALUES(DimCoder[CoderID]),
        [Actual CPH] * CALCULATE(SUM(FactProduction[ProductiveHours]))
    ),
    SUM(FactProduction[ProductiveHours]),
    0
)
</code></pre>
<p>A simple AVERAGE of individual CPH figures would overweight coders who worked fewer hours. This measure gives you the correct team-level productivity number.</p>
<hr />
<h2>Dashboard Layout</h2>
<p>I kept the layout to three pages:</p>
<p><strong>Page 1 — Daily Summary</strong></p>
<ul>
<li><p>KPI cards: Team CPH, Total Codes, Hours Logged</p>
</li>
<li><p>Table: Each coder, their CPH, target, variance, and status (colour-coded)</p>
</li>
<li><p>Slicer: Date (defaults to today), Client</p>
</li>
</ul>
<p><strong>Page 2 — Client Deep Dive</strong></p>
<ul>
<li><p>Bar chart: CPH by client vs. target line</p>
</li>
<li><p>Table: Coder-level breakdown within each client</p>
</li>
</ul>
<p><strong>Page 3 — 30-Day Trend</strong></p>
<ul>
<li><p>Line chart: Daily team CPH vs. target over rolling 30 days</p>
</li>
<li><p>Scatter: Coder performance consistency (average CPH vs. variance)</p>
</li>
</ul>
<hr />
<h2>Things That Tripped Me Up</h2>
<h3>Client Name Mismatches</h3>
<p>As mentioned — "Network Health" vs "NetworkHealth" will silently break your target lookups. I now run a data quality check as part of the refresh process.</p>
<h3>Wellmed NLP Anomaly</h3>
<p>Wellmed uses NLP-assisted coding, which produces much higher CPH numbers. When I first built the dashboard, Wellmed coders were showing as massively over-target — not because they were exceptional, but because the NLP target hadn't been set correctly. Always maintain a separate target row for NLP-assisted clients.</p>
<h3>Blank Dates Breaking the Date Table</h3>
<p>If your production extract has rows with null dates (e.g., records still being processed), those rows fall outside your DimDate table and disappear from all date-filtered visuals. Add a data cleaning step to handle nulls before the data hits your model.</p>
<hr />
<h2>The Outcome</h2>
<p>Once live, the dashboard cut our daily reporting time from about 45 minutes of manual Excel work to zero. Managers open it each morning, see the colour-coded coder table, and immediately know where to focus their attention.</p>
<p>It also changed the conversation in team meetings — instead of debating whether someone was performing well, we had a shared, objective reference point that everyone trusted.</p>
<hr />
<h2>What's Next</h2>
<p>In a future post, I'll cover how we added a <strong>30-day rolling productivity trend</strong> with DAX time intelligence functions, and how we set up <strong>row-level security</strong> so each team lead only sees their own coders.</p>
<hr />
<p><em>Built using Power BI Desktop, with a SQL Server production extract and a manually maintained Excel target lookup table imported as a flat file.</em></p>
<hr />
<p><code>#powerbi</code> <code>#dax</code> <code>#medicalcoding</code> <code>#dataanalytics</code> <code>#businessintelligence</code> <code>#reporting</code></p>
]]></content:encoded></item></channel></rss>