<?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[Tech Things by Bobby]]></title><description><![CDATA[Tech Things by Bobby]]></description><link>https://blog.benstef.com</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Tech Things by Bobby</title><link>https://blog.benstef.com</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 14 May 2026 03:26:16 GMT</lastBuildDate><atom:link href="https://blog.benstef.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[CultureHub — I Built a Zero-Server Team Culture Tracker with Vanilla HTML, CSS & JS]]></title><description><![CDATA[Track events, check in attendees, run a live TV leaderboard, and store photo galleries — all in the browser, no backend required.


The Problem
Most companies have culture events — trivia nights, team]]></description><link>https://blog.benstef.com/culturehub-i-built-a-zero-server-team-culture-tracker-with-vanilla-html-css-js</link><guid isPermaLink="true">https://blog.benstef.com/culturehub-i-built-a-zero-server-team-culture-tracker-with-vanilla-html-css-js</guid><category><![CDATA[office]]></category><category><![CDATA[OfficeEventFood]]></category><category><![CDATA[officeevents]]></category><category><![CDATA[culturehub]]></category><category><![CDATA[Premier Destination for Social Gatherings]]></category><dc:creator><![CDATA[Bobby Stef]]></dc:creator><pubDate>Thu, 14 May 2026 01:15:58 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a0519d6a0c15402778c59a1/8efe4e4d-6f60-4653-80d0-fd6a8b290b25.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>Track events, check in attendees, run a live TV leaderboard, and store photo galleries — all in the browser, no backend required.</p>
</blockquote>
<hr />
<h2>The Problem</h2>
<p>Most companies have culture events — trivia nights, team bowlings, charity drives, holiday parties. Someone books the room, sends the invite, and then... nothing gets tracked. Who showed up? Who won? How engaged is each team over time? Six months later you're trying to justify the culture budget with vibes and a few Slack photos.</p>
<p>I wanted a tool that made this effortless: open it at the event, tap people as they walk in, mark the winner, put the leaderboard on the TV. No logins, no setup, no IT tickets.</p>
<p><strong>CultureHub</strong> is that tool. It runs entirely in the browser, stores everything in <code>localStorage</code>, and ships as a folder of plain HTML files.</p>
<hr />
<h2>What It Does</h2>
<p>Eight pages, one CSS file, one shared data layer:</p>
<table>
<thead>
<tr>
<th>Page</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>index.html</code></td>
<td>Dashboard home — stats, top-5 leaderboard, upcoming events</td>
</tr>
<tr>
<td><code>events.html</code></td>
<td>Create and manage events, filter by type</td>
</tr>
<tr>
<td><code>roster.html</code></td>
<td>Add people or bulk-import from JSON / Microsoft Teams</td>
</tr>
<tr>
<td><code>checkin.html</code></td>
<td>Day-of attendance — tap to check in, mark winners</td>
</tr>
<tr>
<td><code>dashboard.html</code></td>
<td>Live leaderboard with TV fullscreen mode</td>
</tr>
<tr>
<td><code>search.html</code></td>
<td>Global search across people and events</td>
</tr>
<tr>
<td><code>gallery.html</code></td>
<td>Per-event photo grids with drag-drop and slideshow</td>
</tr>
<tr>
<td><code>settings.html</code></td>
<td>Org name, backup / restore, danger zone</td>
</tr>
</tbody></table>
<hr />
<h2>Features</h2>
<h3>📅 Event Management</h3>
<p>Create events with a title, date, type, location, and description. Six built-in types each get a distinct colour badge for fast scanning:</p>
<ul>
<li><p>🎮 <strong>Game</strong> — trivia nights, competitions</p>
</li>
<li><p>🥂 <strong>Social</strong> — happy hours, team lunches</p>
</li>
<li><p>🤝 <strong>Team Building</strong> — workshops, offsites</p>
</li>
<li><p>🎉 <strong>Holiday</strong> — seasonal parties</p>
</li>
<li><p>❤️ <strong>Charity</strong> — volunteer days, fundraisers</p>
</li>
<li><p>📌 <strong>General</strong> — everything else</p>
</li>
</ul>
<p>Each event card shows live check-in counts, registered attendees, points awarded, and photo count at a glance.</p>
<hr />
<h3>👥 Roster &amp; Import</h3>
<p>Add people manually (name, team, email) or bulk-import from a JSON array. The importer handles both a native format and the Microsoft Teams / Azure AD admin export:</p>
<pre><code class="language-json">[
  { "name": "Alice Johnson", "team": "Engineering", "email": "alice@co.com" },
  { "displayName": "Bob Smith", "mail": "bob@co.com", "department": "Marketing" }
]
</code></pre>
<p>Both formats work — the importer checks for <code>name</code> or <code>displayName</code>, <code>email</code> or <code>mail</code>, <code>team</code> or <code>department</code>. Duplicate detection prevents double-imports.</p>
<hr />
<h3>✅ Check-In &amp; Scoring</h3>
<p>The check-in page is the core day-of tool. Open it on a laptop or tablet at the event:</p>
<ul>
<li><p><strong>Tap a card</strong> → person is checked in, scores <strong>1 point</strong></p>
</li>
<li><p><strong>Tap 🏆</strong> → mark as winner, scores <strong>3 points</strong></p>
</li>
<li><p><strong>Tap again</strong> → undo the check-in</p>
</li>
<li><p><strong>Check All In</strong> → bulk check-in the whole registered list</p>
</li>
</ul>
<p>The scoring is intentionally simple:</p>
<table>
<thead>
<tr>
<th>Action</th>
<th>Points</th>
</tr>
</thead>
<tbody><tr>
<td>✅ Attending and checking in</td>
<td>1 pt</td>
</tr>
<tr>
<td>🏆 Winning an event</td>
<td>3 pts</td>
</tr>
<tr>
<td>Points cap</td>
<td>None — accumulate forever</td>
</tr>
</tbody></table>
<p>Tie-breaking on the leaderboard: equal points → sorted by number of events attended.</p>
<hr />
<h3>📺 TV Dashboard &amp; Live Leaderboard</h3>
<p>The dashboard page doubles as a lobby display. Hit <strong>TV Mode</strong> (or <code>Alt+T</code> / <code>F11</code>) to go fullscreen with the nav hidden:</p>
<ul>
<li><p>Animated rank rows with gold/silver/bronze highlights</p>
</li>
<li><p>A scrolling ticker cycling through top scores and recent events</p>
</li>
<li><p>Event attendance progress bars in the sidebar</p>
</li>
<li><p>Auto-refreshes every 30 seconds</p>
</li>
<li><p><code>Esc</code> to exit TV Mode</p>
</li>
</ul>
<p>This is the one that gets the room going at the end of an event.</p>
<hr />
<h3>🖼 Photo Gallery</h3>
<p>Per-event photo grids with drag-and-drop upload. Click any photo to open a fullscreen slideshow — navigate with arrow keys, close with <code>Esc</code>.</p>
<blockquote>
<p>⚠️ <strong>Storage note:</strong> Photos are stored as base64 data URLs in <code>localStorage</code>, which browsers typically cap at 5–10 MB. Use the built-in Backup feature regularly. A "Storage full" toast will appear if you hit the quota.</p>
</blockquote>
<hr />
<h3>🔍 Search &amp; PDF Reports</h3>
<p>Global search across people and events with a split-panel detail view — click a result on the left to see full stats on the right without leaving the page.</p>
<p>Three report types, all generated client-side as print-ready popups:</p>
<ul>
<li><p><strong>Overview report</strong> — top-10 leaderboard + full events table</p>
</li>
<li><p><strong>Event report</strong> — attendance list with status and points</p>
</li>
<li><p><strong>Person report</strong> — individual stats with event history</p>
</li>
</ul>
<hr />
<h2>Getting Started</h2>
<p>No install. No build step. No account.</p>
<p><strong>Step 1 — Configure your org</strong></p>
<p>Open <code>settings.html</code>, set your organisation name. Hit <strong>Load Sample Data</strong> to pre-populate 8 people and 4 events so you can explore every feature immediately.</p>
<p><strong>Step 2 — Add your team</strong></p>
<p>Go to <code>roster.html</code> → <strong>Import</strong> and paste your team JSON, or add people one by one with <strong>+ Add Person</strong>.</p>
<p><strong>Step 3 — Create your first event</strong></p>
<p>Go to <code>events.html</code> → <strong>+ New Event</strong>. Give it a name, date, type, and location.</p>
<p><strong>On the day:</strong></p>
<ol>
<li><p>Open <code>checkin.html</code> on a tablet at the door</p>
</li>
<li><p>Tap people as they arrive</p>
</li>
<li><p>Mark winners with 🏆 when the competition ends</p>
</li>
<li><p>Open <code>dashboard.html</code> on a screen at the front of the room</p>
</li>
</ol>
<hr />
<h2>Architecture</h2>
<h3>The philosophy: no framework, no build step</h3>
<p>The whole thing is three files doing all the heavy lifting:</p>
<pre><code class="language-plaintext">culturehub/
├── data.js      ← data layer (IIFE, localStorage, event bus)
├── utils.js     ← UI helpers (nav, theme, toasts, modals, reports)
└── style.css    ← complete design system (dark + light, ~380 lines)
</code></pre>
<p>Each HTML page is ~150–280 lines of vanilla JS, all following the same pattern:</p>
<pre><code class="language-js">// Every page — same three lines to bootstrap
document.getElementById('nav-root').innerHTML = navHTML();
setActiveNav();

// Reactive render — re-runs whenever any data changes
function render() { /* build innerHTML */ }
window.addEventListener('culturehub:updated', render);
render();
</code></pre>
<hr />
<h3><code>data.js</code> — the data layer</h3>
<p>An IIFE that exposes the <code>CH</code> singleton globally. All reads and writes go through it — nothing touches <code>localStorage</code> directly from a page.</p>
<pre><code class="language-js">const CH = (() =&gt; {
  const KEY = 'culturehub_data';

  function save(data) {
    localStorage.setItem(KEY, JSON.stringify(data));
    window.dispatchEvent(new CustomEvent('culturehub:updated', { detail: data }));
  }

  // ... all CRUD methods

  return { getPeople, addPerson, getEvents, addEvent, checkIn, toggleWinner, ... };
})();
</code></pre>
<p>Every mutation calls <code>save()</code>, which serialises the whole state and fires <code>culturehub:updated</code>. Every open page listens for that event and re-renders — no state management library needed.</p>
<p>The public API:</p>
<pre><code class="language-plaintext">CH.getPeople()       CH.addPerson(name, team, email)
CH.getEvents()       CH.addEvent(title, date, type, desc, location)
CH.checkIn(evId, pId)       CH.toggleWinner(evId, pId)
CH.getLeaderboard()         CH.getPersonStats(personId)
CH.exportBackup()           CH.importBackup(jsonStr)
</code></pre>
<hr />
<h3><code>utils.js</code> — shared UI helpers</h3>
<p>Loaded on every page after <code>data.js</code>. Provides:</p>
<pre><code class="language-plaintext">Theme.toggle()          toast(msg, type, duration)
openModal(html)         confirmDialog(message, title)
avatarEl(name, size)    fmtDate(dateStr)
eventTypeBadge(type)    generateReport(type, id)
navHTML()               setActiveNav()
</code></pre>
<p>The nav is a function — <code>navHTML()</code> returns the header markup as a string, injected into a <code>&lt;div id="nav-root"&gt;</code> on every page. It reads the org name from settings on each call, so renaming the org updates all pages on next load.</p>
<hr />
<h3><code>style.css</code> — the design system</h3>
<p>A single CSS file with full dark/light theming via custom properties:</p>
<pre><code class="language-css">:root,
html[data-theme="dark"] {
  --bg:     #07090f;
  --accent: #00c9b1;
  --gold:   #ffb830;
  /* ... */
}

html[data-theme="light"] {
  --bg:     #f2f5fc;
  --accent: #007f6f;
  /* ... */
}
</code></pre>
<p>All colours, shadows, and radii reference these tokens — switching theme is a single attribute change on <code>&lt;html&gt;</code>.</p>
<p><strong>FOUC prevention:</strong> Every page has a tiny inline script in <code>&lt;head&gt;</code> that applies the saved theme before the stylesheet parses, eliminating any flash of the wrong theme on load:</p>
<pre><code class="language-html">&lt;script&gt;
  (function(){
    var t = localStorage.getItem("culturehub_theme") || "dark";
    document.documentElement.setAttribute("data-theme", t);
  })();
&lt;/script&gt;
</code></pre>
<hr />
<h2>Data Model</h2>
<p>Everything lives under a single <code>culturehub_data</code> key in <code>localStorage</code>:</p>
<pre><code class="language-json">{
  "version": 1,
  "settings": {
    "orgName": "Our Organization",
    "theme": "dark"
  },
  "people": [
    {
      "id": "lx3k8a",
      "name": "Alice Johnson",
      "team": "Engineering",
      "email": "alice@company.com",
      "avatar": ""
    }
  ],
  "events": [
    {
      "id": "m2p9xq",
      "title": "Summer Trivia Night",
      "date": "2025-07-15",
      "type": "Game",
      "description": "Teams compete in trivia!",
      "location": "Rooftop",
      "gallery": [
        { "id": "...", "url": "data:image/jpeg;base64,...", "caption": "" }
      ],
      "attendees": [
        {
          "personId": "lx3k8a",
          "checkedIn": true,
          "points": 3,
          "isWinner": true
        }
      ]
    }
  ]
}
</code></pre>
<p><code>attendees[].personId</code> is a soft foreign key to <code>people[].id</code>. The leaderboard calculation handles the edge case of an attendee whose person record was later deleted — it skips gracefully rather than crashing.</p>
<p>UIDs are generated as <code>Date.now().toString(36) + Math.random().toString(36).substr(2, 6)</code> — not cryptographically secure, but collision-proof enough for a local-only app.</p>
<hr />
<h2>Backup &amp; Restore</h2>
<p>The <strong>💾 Backup</strong> button in the nav bar (or Settings) downloads the full <code>culturehub_data</code> object as a dated JSON file:</p>
<pre><code class="language-plaintext">culturehub-backup-2025-07-15.json
</code></pre>
<p>Photos are embedded as base64, so the file is completely self-contained. To restore: Settings → load the file or paste the JSON → click Restore. The importer validates that <code>people</code> and <code>events</code> keys exist before writing anything.</p>
<hr />
<h2>Keyboard Shortcuts</h2>
<table>
<thead>
<tr>
<th>Shortcut</th>
<th>Action</th>
</tr>
</thead>
<tbody><tr>
<td><code>Alt + T</code></td>
<td>Toggle TV Mode on Dashboard</td>
</tr>
<tr>
<td><code>F11</code></td>
<td>Toggle TV Mode on Dashboard</td>
</tr>
<tr>
<td><code>Esc</code></td>
<td>Exit TV Mode</td>
</tr>
<tr>
<td><code>→</code></td>
<td>Next photo in slideshow</td>
</tr>
<tr>
<td><code>←</code></td>
<td>Previous photo in slideshow</td>
</tr>
<tr>
<td><code>Esc</code></td>
<td>Close slideshow or modal</td>
</tr>
</tbody></table>
<hr />
<h2>What I'd Change Next</h2>
<p>A few things I've noted for future iterations:</p>
<ul>
<li><p><strong>Photo storage</strong> — base64 in localStorage is the biggest practical constraint. The natural next step is the <a href="https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system">Origin Private File System API</a> (<code>navigator.storage.getDirectory()</code>), which would lift the 5–10 MB cap entirely and keep everything local.</p>
</li>
<li><p><strong>CSV import</strong> — the roster importer handles JSON today. A CSV path would make it easier to copy-paste from a spreadsheet without any JSON formatting.</p>
</li>
<li><p><strong>Points customisation</strong> — the 1pt / 3pt system is hardcoded. Exposing it in Settings would take five minutes and make the app more flexible.</p>
</li>
</ul>
<hr />
<h2>Final Thoughts</h2>
<p>The constraint of "no server, no framework, no build step" turned out to be a feature. The whole app is inspectable, forkable, and deployable by dropping a folder anywhere — a local filesystem, a USB drive, a GitHub Pages site, an intranet server. There's nothing to break, no dependencies to update, no account to log into.</p>
<p>If your team runs culture events and you want something you can actually own, give it a try.</p>
<hr />
<p><em>Built with vanilla HTML, CSS, and JavaScript. All data stored locally in the browser — no server, no sign-up, no cloud.</em></p>
]]></content:encoded></item><item><title><![CDATA[🚀 Claude AI + Zoom Automation: Building an AI Meeting Agent]]></title><description><![CDATA[TL;DR
I built a system that connects Claude AI with Zoom to automatically:

Summarize meetings

Extract action items

Trigger follow-ups



🧠 The Idea
Meetings create a lot of friction:

Notes get lo]]></description><link>https://blog.benstef.com/claude-ai-zoom-automation-building-an-ai-meeting-agent</link><guid isPermaLink="true">https://blog.benstef.com/claude-ai-zoom-automation-building-an-ai-meeting-agent</guid><category><![CDATA[zoom]]></category><category><![CDATA[zoommcp]]></category><category><![CDATA[AI]]></category><category><![CDATA[#ai-tools]]></category><category><![CDATA[mcp]]></category><category><![CDATA[mcp server]]></category><category><![CDATA[MCP-host]]></category><category><![CDATA[MCP Client]]></category><dc:creator><![CDATA[Bobby Stef]]></dc:creator><pubDate>Thu, 14 May 2026 00:57:20 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a0519d6a0c15402778c59a1/528b86dc-5e09-4f79-b183-353b3774884b.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>TL;DR</h2>
<p>I built a system that connects Claude AI with Zoom to automatically:</p>
<ul>
<li><p>Summarize meetings</p>
</li>
<li><p>Extract action items</p>
</li>
<li><p>Trigger follow-ups</p>
</li>
</ul>
<hr />
<h2>🧠 The Idea</h2>
<p>Meetings create a lot of friction:</p>
<ul>
<li><p>Notes get lost</p>
</li>
<li><p>Action items are forgotten</p>
</li>
<li><p>Follow-ups take time</p>
</li>
</ul>
<p>So I built a system where AI handles the entire workflow.</p>
<hr />
<h2>⚙️ Architecture</h2>
<pre><code class="language-plaintext">Zoom → Webhook → Backend → Claude → Action Engine → Tools
</code></pre>
<hr />
<h2>🔌 Zoom Webhook Example</h2>
<pre><code class="language-javascript">import express from "express";

const app = express();
app.use(express.json());

app.post("/zoom/webhook", async (req, res) =&gt; {
  if (req.body.event === "recording.completed") {
    const url = req.body.payload.object.recording_files[0].download_url;
    await processMeeting(url);
  }
  res.sendStatus(200);
});

app.listen(3000);
</code></pre>
<hr />
<h2>🧠 Claude Processing</h2>
<pre><code class="language-javascript">async function analyzeMeeting(transcript) {
  const res = await fetch("https://api.anthropic.com/v1/messages", {
    method: "POST",
    headers: {
      "x-api-key": process.env.CLAUDE_API_KEY,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      model: "claude-3-opus",
      messages: [{
        role: "user",
        content: `Summarize and extract action items:\n${transcript}`
      }]
    })
  });

  return res.json();
}
</code></pre>
<hr />
<h2>📌 Structured Output</h2>
<pre><code class="language-json">{
  "summary": "...",
  "decisions": ["..."],
  "actions": [
    { "task": "...", "owner": "..." }
  ]
}
</code></pre>
<hr />
<h2>📬 Follow-Up Automation</h2>
<pre><code class="language-javascript">async function sendFollowUp(summary, actions) {
  console.log(summary, actions);
}
</code></pre>
<hr />
<h2>🤖 Why This Matters</h2>
<p>This is more than automation.</p>
<p>It’s the shift from: AI as a tool → AI as an operator</p>
<hr />
<h2>🔥 Use Cases</h2>
<ul>
<li><p>Devs → auto-create tickets</p>
</li>
<li><p>Teams → auto-sync decisions</p>
</li>
<li><p>Founders → scale across meetings</p>
</li>
</ul>
<hr />
<h2>🧭 Future</h2>
<p>AI agents that:</p>
<ul>
<li><p>Join meetings</p>
</li>
<li><p>Make decisions</p>
</li>
<li><p>Execute tasks</p>
</li>
</ul>
<hr />
<h2>💬 Final Thought</h2>
<p>The goal isn’t better meetings.</p>
<p>It’s meetings that do the work for you.</p>
]]></content:encoded></item></channel></rss>