<?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[VanillaPM]]></title><description><![CDATA[Free, full-PMBOK project management — WBS, Gantt, Earned Value, risk registers, a governed knowledge base and grounded AI. Charter to closeout, built in the ope]]></description><link>https://vanillapm.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a968036a5b7111df9bb7964/69fb20ad-6502-41f1-a77d-0d9158b505a6.png</url><title>VanillaPM</title><link>https://vanillapm.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 11 Oct 2026 17:59:21 GMT</lastBuildDate><atom:link href="https://vanillapm.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Your issue tracker is not an issue log]]></title><description><![CDATA[I spent years as a developer with a tidy issue tracker and a recurring, stupid problem: the things that actually derailed our projects were almost never in it.
The database migration that needed a DBA]]></description><link>https://vanillapm.hashnode.dev/your-issue-tracker-is-not-an-issue-log</link><guid isPermaLink="true">https://vanillapm.hashnode.dev/your-issue-tracker-is-not-an-issue-log</guid><category><![CDATA[management]]></category><category><![CDATA[Productivity]]></category><category><![CDATA[Career]]></category><category><![CDATA[agile]]></category><dc:creator><![CDATA[Ahsan Ahmad]]></dc:creator><pubDate>Thu, 08 Oct 2026 15:20:25 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a968036a5b7111df9bb7964/f693c1dd-8e4d-4a0c-9d80-32e14650ed03.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I spent years as a developer with a tidy issue tracker and a recurring, stupid problem: the things that actually derailed our projects were almost never in it.</p>
<p>The database migration that needed a DBA who was on leave. The client contact who stopped replying for three weeks. The staging environment nobody owned. None of these were tickets. All of them cost us more than any bug we shipped.</p>
<p>I only got a clean vocabulary for this when I started studying project management properly. The distinction turns out to be small, old, and genuinely useful — so I want to hand it over without the certification padding.</p>
<h2>The distinction: an issue is not a risk</h2>
<p>Here is the whole thing:</p>
<ul>
<li><p>A <strong>risk</strong> is a problem that <em>might</em> happen. Future, probabilistic.</p>
</li>
<li><p>An <strong>issue</strong> is a problem that <em>is</em> happening. Present, certain.</p>
</li>
<li><p>When a risk materialises, it stops being a risk and becomes an issue.</p>
</li>
</ul>
<p>That last line is the useful part. Risks are not a list you write once at kickoff and never revisit — they are supposed to <em>graduate</em>. "The lift supplier might get held up at customs" is a risk. The morning the lifts are actually sitting in a customs warehouse, it is an issue, and it needs a different response: not monitoring, but an owner and an action today.</p>
<p>Most teams I have seen have no mechanism for that transition. The risk list lives in a document from month one. The issue nobody saw coming lands in standup as a surprise. It was not a surprise — it was on page two.</p>
<h2>Why your tracker can't hold this</h2>
<p>Nothing stops you putting "lifts stuck at customs" in Jira. People do. It is just that the tracker is already doing a different job, and the two jobs fight.</p>
<p>Your tracker holds <strong>work you intend to do</strong>. Issues are <strong>problems blocking work you already planned</strong>. Mixed into one backlog:</p>
<ul>
<li><p>The blocker gets a priority label and sits in a column with forty other things.</p>
</li>
<li><p>It has an assignee — but the assignee is usually whoever will <em>do the work after the blocker clears</em>, not the person who can clear it. Those are frequently different people, and often the blocker-clearer is not an engineer at all.</p>
</li>
<li><p>It has no escalation path. A ticket's escalation is "move it up the backlog". A real blocker's escalation is "this needs the sponsor to make a call by Thursday".</p>
</li>
<li><p>It closes when the code merges. But most impediments are cleared by a conversation, a decision, or a payment — nothing merges, so nothing closes.</p>
</li>
</ul>
<p>The mismatch is not about tooling. It is that one of these is a work queue and the other is a blocker list, and a blocker list is read by different people, at a different cadence, with different authority to act.</p>
<h2>RAID, in ninety seconds</h2>
<p>The frame this comes from is <strong>RAID</strong> — four registers, kept separately because they get different treatment:</p>
<table>
<thead>
<tr>
<th></th>
<th>What it is</th>
<th>What you do with it</th>
</tr>
</thead>
<tbody><tr>
<td><strong>R</strong>isks</td>
<td>Might happen</td>
<td>Monitor; plan a response in advance</td>
</tr>
<tr>
<td><strong>A</strong>ssumptions</td>
<td>Believed true, unverified</td>
<td>Validate before it bites you</td>
</tr>
<tr>
<td><strong>I</strong>ssues</td>
<td>Happening now</td>
<td>Own it, date it, drive it closed</td>
</tr>
<tr>
<td><strong>D</strong>ependencies</td>
<td>Someone else's work gates yours</td>
<td>Track the other party, not your own team</td>
</tr>
</tbody></table>
<p>It looks like bureaucracy. In practice, for a team of any size, it is four lists and a weekly ten minutes. The value is almost entirely in the <em>separation</em> — assumptions in particular, because an unexamined assumption is the single most common way a plan quietly becomes wrong.</p>
<h2>What actually changes in practice</h2>
<p>Four things, and they are cheap:</p>
<p><strong>One named owner per issue — a person, not a team.</strong> "Platform team" owns nothing. Unowned issues drift, and drift is the default state of a blocker that is inconvenient for everyone.</p>
<p><strong>A date, not a priority.</strong> Priorities inflate until everything is P1. A date is falsifiable: Thursday arrives and either it moved or it did not.</p>
<p><strong>An explicit escalation path.</strong> Before you need it. "If this is not cleared by Thursday, it goes to X." Most blockers persist not because they are hard but because nobody knew who was allowed to decide.</p>
<p><strong>Review and close, in a fixed cadence.</strong> The failure mode of every issue log is becoming a graveyard — forty open entries, half resolved months ago, nobody trusting it enough to read it. A log nobody trusts is worse than no log, because it creates the feeling of control without any.</p>
<h2>The part that reframed the job for me</h2>
<p>In the PM literature this task is not called "track issues". It is called <strong>remove impediments</strong> — and the framing is deliberate. Tracking is bookkeeping. The job is clearing the path in front of your team.</p>
<p>That reads as soft-skills fluff until you notice it changes what you do on a Tuesday. Logging "waiting on legal review" is bookkeeping. Walking to legal, finding out the review is stuck on a question nobody asked them, and answering it — that is the job. The log exists to make sure you <em>know</em> which path to clear, not to be the work itself.</p>
<p>For anyone moving from senior dev toward lead or EM: this is a large part of what the role actually is, and it is nearly invisible from the IC seat. It looks like someone going to a lot of meetings. Sometimes it is. Sometimes it is the only reason your sprint finished.</p>
<h2>If you want the longer version</h2>
<p>I have been writing up the PMP exam content as practical lessons while studying it, with worked examples — <a href="https://vanillapm.com/learn/pmp/manage-issues/">the one on issue logs and removing impediments is here</a>. It covers the risk→issue escalation path in more detail, and what the exam expects you to do when a blocker appears (short version: act to clear it, don't just log it).</p>
<p>Full disclosure: I build <a href="https://vanillapm.com">VanillaPM</a>, an open project management tool, and the lessons are part of it. They are free and ungated — I wrote them to learn the material myself, and publishing them seemed better than letting the notes rot in a folder.</p>
<hr />
<p><strong>The one thing worth stealing from this post</strong>, if you take nothing else: separate the problems that <em>are</em> happening from the work you <em>intend</em> to do. They need different owners, different cadences, and different people reading them. One backlog cannot do both jobs, and the one it quietly stops doing is the one that sinks projects.</p>
]]></content:encoded></item><item><title><![CDATA[Grounded AI: making an LLM draft a project charter from real data — without hallucinating]]></title><description><![CDATA["Add AI" is the easiest line item on any 2026 roadmap and the easiest way to ship something worse than nothing. Wire an LLM to a "Generate charter" button, feed it the project name, and it will happil]]></description><link>https://vanillapm.hashnode.dev/grounded-ai-making-an-llm-draft-a-project-charter-from-real-data-without-hallucinating</link><guid isPermaLink="true">https://vanillapm.hashnode.dev/grounded-ai-making-an-llm-draft-a-project-charter-from-real-data-without-hallucinating</guid><category><![CDATA[AI]]></category><category><![CDATA[Django]]></category><category><![CDATA[llm]]></category><category><![CDATA[Python]]></category><dc:creator><![CDATA[Ahsan Ahmad]]></dc:creator><pubDate>Fri, 18 Sep 2026 05:53:03 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a968036a5b7111df9bb7964/b63e1777-8e2a-40b2-8b59-3819cf012113.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>"Add AI" is the easiest line item on any 2026 roadmap and the easiest way to ship something worse than nothing. Wire an LLM to a "Generate charter" button, feed it the project name, and it will happily produce a beautifully-worded project charter full of objectives, success criteria and stakeholders that <strong>do not exist</strong>. In a governed project-management tool, a confident invention is worse than a blank field — someone might <em>believe</em> it.</p>
<p>I wanted the generate button anyway, because there's a real version of it. Here's how I built a charter generator that drafts genuinely useful prose from the user's <em>actual</em> data, and refuses to make things up — in Django, bring-your-own-key, no framework magic.</p>
<h2>Two things this is not</h2>
<p><strong>Not autopopulate.</strong> I already had a deterministic autopopulate: copy the sponsor from the project record, the dates from the schedule, and so on. That's great for <em>fields</em>, but a charter is mostly <em>prose</em> — a scope statement, an objectives section, a rationale — synthesised from the pre-project inputs (the business case, the benefits plan, the agreements). You can't <code>str()</code> your way to a scope statement. Autopopulate structurally can't do it.</p>
<p><strong>Not a free-form LLM.</strong> The other extreme — "here's the project name, write me a charter" — is exactly the hallucination machine. The model has no idea what your business case says, so it invents a plausible one.</p>
<p>The useful thing lives in between: <strong>grounded synthesis.</strong> Read the real input documents, and ask the model to <em>compose</em> — using only what's actually there.</p>
<h2>Step 1: ground in the real inputs, not the model's imagination</h2>
<p>The whole trick is that the model's only source of truth is text <em>you</em> supply from the user's own documents — never its training data.</p>
<p>In VanillaPM every document type declares its <strong>input documents</strong> (its ITTO prerequisites — the things that must exist before it). A charter's inputs are the business case, the benefits management plan, the agreements. So "grounding" is concrete: go read <em>those</em> documents.</p>
<pre><code class="language-python">DOC_CHAR_CAP     = 8_000    # per input document
TOTAL_CONTEXT_CAP = 40_000  # the whole grounding context

def input_documents(project, doc_type):
    """(type, title, text) for each declared prerequisite that exists and has
    readable text — each capped so one long doc can't dominate the context."""
    out = []
    for prereq in prerequisite_types(project, doc_type):   # from the dependency graph
        doc = latest_of_type(project, prereq)
        text = document_text(doc)          # walk the stored rich-text → plain text
        if text:
            out.append((prereq, doc.title, text[:DOC_CHAR_CAP]))
    return out
</code></pre>
<p>Two boring-but-important details:</p>
<ul>
<li><p><strong>Cap per document <em>and</em> in total.</strong> One 200-page attachment shouldn't crowd out the other inputs (or blow your context window / bill). A per-doc cap plus a total cap keeps the grounding balanced and bounded.</p>
</li>
<li><p><strong>Ground in <em>authored</em> text, not the whole DB.</strong> <code>document_text()</code> walks the document's stored rich-text tree and pulls the prose the human actually wrote — not a JSON dump of every model.</p>
</li>
</ul>
<p>The grounding context is then just: the deterministic structured facts (reused from autopopulate) <strong>+</strong> these input-document excerpts, under a big honest header.</p>
<h2>Step 2: the system prompt does the anti-hallucination work</h2>
<p>This is the part people skip. The model will fabricate <em>unless you make refusing the easier path.</em> Three rules, and the second one is the whole ballgame:</p>
<pre><code class="language-python">SYSTEM_PROMPT = (
    "You draft sections of a PMBOK-aligned project document.\n"
    "1. Ground every section ONLY in the provided project data and input "
    "   documents below. They are your only source of truth.\n"
    "2. If the data a section needs is missing, write ONE short professional "
    "   sentence stating what input is still needed — do NOT fabricate.\n"
    "3. Return JSON only: "
    '{"sections":[{"key":"&lt;section_key&gt;","body":"&lt;prose&gt;"}]}. '
    "Use only the section keys given. No markdown headings, no preamble."
)
</code></pre>
<p>Rule 2 is the difference between a demo and a tool. Giving the model an <strong>acceptable way to say "I don't have this"</strong> — a short "needs input: the benefits plan doesn't state a target ROI" placeholder — means it takes that exit instead of inventing a number. You're not just <em>asking</em> it not to hallucinate; you're handing it a better move than hallucinating.</p>
<p>The user turn wires the sections to draft onto the grounding context:</p>
<pre><code class="language-python">USER_PROMPT = (
    "Draft these sections of the project's {doc_label}. Use each key exactly:\n\n"
    "{targets}\n\n"
    "=== PROJECT DATA AND INPUT DOCUMENTS (your only source of truth) ===\n"
    "{context}"
)
</code></pre>
<p><strong>Structured JSON out, fixed keys in.</strong> The model returns <code>{"sections":[{"key", "body"}]}</code> and may only use keys I gave it. That does two jobs: parsing is trivial and deterministic, and the model can't wander off and invent a "Section 12: Executive Bonus Plan." It fills the blanks I asked for, or it says it can't.</p>
<h2>Step 3: only touch the blanks, never the real data</h2>
<p>A charter is a mix of <em>authored</em> prose sections and <em>live-data</em> sections (a stakeholder table, a risk snapshot) that are generated from real records. The AI must never touch the latter — those aren't opinions to draft, they're facts to render.</p>
<pre><code class="language-python">def target_sections(content_json, overwrite=False):
    for node in content_json.get("content", []):
        if section_has_datablock(node):        # a live register/table snapshot
            continue                           # generated from real data — hands off
        if section_has_text(node) and not overwrite:
            continue                           # non-destructive: only fill empty sections
        yield node["attrs"]["key"], node["attrs"]["title"]
</code></pre>
<p>So the fill is <strong>non-destructive</strong> (it never overwrites what a human wrote unless they ask) and it <strong>skips live-data sections entirely</strong>. The model works on empty <em>prose</em> sections and nothing else. Bonus: because the section keys/titles come from the document itself, the exact same code drafts a communications plan or a risk-management plan — it generalised for free.</p>
<p>And the output lands in <strong>draft</strong>, for a human to review and approve. The AI proposes; the PM decides. It's never a fact until a person signs off.</p>
<h2>Step 4: bring-your-own-key, and fail safe</h2>
<p>No forced AI markup, and no crash when AI isn't configured. The path resolves BYOK-first:</p>
<pre><code class="language-python">def resolve_ai_path(user, org):
    if byok_available(user, org):                     # user's own key
        return "byok", user_key(user), MODEL_BYOK     #   → free through us
    if managed_available(user, org):                  # metered credits
        return "managed", server_key(), MODEL_MANAGED
    return None, None, None                           # feature cleanly disabled
</code></pre>
<p>And the SDK import is <strong>guarded</strong> — a server without the <code>anthropic</code> package (or a user without a key) degrades to a disabled button, never an exception:</p>
<pre><code class="language-python">try:
    import anthropic
    _AVAILABLE = True
except ImportError:
    anthropic, _AVAILABLE = None, False
</code></pre>
<p>Nothing in the module holds a key; the caller passes the user's decrypted key in per request. (Load tests swap in an offline stub so they never hit the real API — cost and rate limits stay off the test path.)</p>
<h2>What grounding buys you — and what it doesn't</h2>
<p>Grounding isn't a hallucination <em>cure</em>; it's a hallucination <em>budget</em>. What it reliably buys:</p>
<ul>
<li><p>The model composes from <strong>your</strong> business case, so the objectives are <em>your</em> objectives.</p>
</li>
<li><p>Missing inputs surface as honest "needs input" notes — which doubles as a checklist of what to go write.</p>
</li>
<li><p>Structured, key-scoped output means no invented sections and trivial parsing.</p>
</li>
<li><p>Non-destructive, draft-only, human-approved: the AI can't silently rewrite a governed document.</p>
</li>
</ul>
<p>What it doesn't do: it won't fix a vague business case (garbage in, grounded-garbage out), and a determined model can still mis-synthesise. So it's a <em>draft</em> generator with a human gate, not an autopilot — which is exactly what a governed document deserves.</p>
<p>The pattern generalises well beyond charters: <strong>read the real inputs, cap and label them as the only source of truth, give the model an honest "I don't know," constrain the output shape, and never let it touch data it should only render.</strong> That's most of the distance between "we added AI" and AI you can actually put near real work.</p>
<hr />
<p><em>I'm building a free, full-lifecycle PM platform in the open — this generator ships in it (BYOK). If the build-in-public engineering is your thing, the rest of the series is here.</em></p>
]]></content:encoded></item><item><title><![CDATA[Keeping Alpine.js + Django templates sane in a large app]]></title><description><![CDATA[I built a big app the "boring" way: server-rendered Django templates + Alpine.js for interactivity, no SPA. ~50 Django apps, a lot of screens, one developer. Alpine is the perfect amount of JavaScript]]></description><link>https://vanillapm.hashnode.dev/keeping-alpine-js-django-templates-sane-in-a-large-app</link><guid isPermaLink="true">https://vanillapm.hashnode.dev/keeping-alpine-js-django-templates-sane-in-a-large-app</guid><category><![CDATA[Django]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[alpinejs]]></category><dc:creator><![CDATA[Ahsan Ahmad]]></dc:creator><pubDate>Mon, 07 Sep 2026 11:05:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a968036a5b7111df9bb7964/29790ce2-d54c-46f9-a6b0-45852178be00.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I built a big app the "boring" way: <strong>server-rendered Django templates + Alpine.js for interactivity, no SPA.</strong> ~50 Django apps, a lot of screens, one developer. Alpine is the perfect amount of JavaScript for this — until the app gets large and you start hitting its sharp edges.</p>
<p>Here are the patterns that kept it maintainable, and the gotchas that cost me real hours. All of it is stuff I wish someone had told me at line 1.</p>
<h2>The mental model: islands, not an app</h2>
<p>The trap with Alpine in a big project is treating it like a mini-SPA — global stores everywhere, components reaching into each other. Don't. Treat each interactive region as an <strong>island</strong>: a self-contained Alpine component hydrating one chunk of server-rendered HTML. The server owns the data and the page; Alpine owns <em>this widget's</em> behaviour.</p>
<p>Two rules fall out of that, and they're the difference between "fine at 10 screens" and "fine at 200."</p>
<h2>Rule 1: register components — stop writing logic inline</h2>
<p>Inline <code>x-data="{ ... }"</code> is lovely for a toggle. For anything real, it's a liability — and it has a <em>nasty</em> failure mode.</p>
<p>Put a literal double-quote inside an inline expression and you silently close the HTML attribute early:</p>
<pre><code class="language-html">&lt;!-- ❌ Alpine sees x-data="{ label: "  — the rest becomes broken markup --&gt;
&lt;div x-data="{ label: "Save", open: false }"&gt;
</code></pre>
<p>The component doesn't throw. It just… doesn't initialize, and depending on where the parser gives up, your expression can <strong>dump into the page as visible text</strong>. Worse, it often <em>renders fine in your quick manual check</em> but breaks somewhere else — and it's invisible to a lot of smoke tests because it's an HTML-parse issue, not a JS error.</p>
<p>The fix isn't "remember to use single quotes." It's: <strong>anything beyond a line or two becomes a registered component.</strong></p>
<pre><code class="language-js">// static/js/components.js
document.addEventListener('alpine:init', () =&gt; {
  Alpine.data('projectBoard', () =&gt; ({
    open: false,
    label: 'Save',           // real quotes, real editor, real linting
    toggle() { this.open = !this.open },
  }))
})
</code></pre>
<pre><code class="language-html">&lt;div x-data="projectBoard"&gt; … &lt;/div&gt;
</code></pre>
<p>Now the logic lives in a <code>.js</code> file your editor and linter understand, the template stays declarative, and the quoting footgun is gone.</p>
<h2>Rule 2: never put a "rich" JS instance on reactive state</h2>
<p>This one cost me the most time, so I'll be specific. Alpine makes your state reactive by wrapping it in a <strong>deep Proxy</strong> (via <code>@vue/reactivity</code>). That's great for plain data. It's <em>poison</em> for objects that do their own identity bookkeeping internally — a <strong>TipTap/ProseMirror editor</strong>, a map instance, a <code>&lt;canvas&gt;</code> controller, a WebSocket.</p>
<pre><code class="language-js">// ❌ ProseMirror starts throwing "Applying a mismatched transaction"
Alpine.data('editor', () =&gt; ({
  editor: null,
  init() { this.editor = new Editor({ element: this.$refs.box }) }, // now a Proxy
}))
</code></pre>
<p>The editor stores references to its own nodes/state and compares them by identity. Once Alpine has proxied it, <code>this.editor</code> is <em>not</em> the object the editor thinks it is, and its internal <code>===</code> checks fail in baffling ways.</p>
<p>The fix: <strong>keep non-plain instances off reactive state entirely.</strong> A closure is cleanest:</p>
<pre><code class="language-js">// ✅ the editor lives in closure scope — never proxied
Alpine.data('editor', () =&gt; {
  let editor
  return {
    init()    { editor = new Editor({ element: this.$refs.box }) },
    bold()    { editor.chain().focus().toggleBold().run() },
    destroy() { editor?.destroy() },
  }
})
</code></pre>
<p>If it <em>must</em> live on <code>this</code>, mark it so the reactivity engine skips it (<code>obj.__v_skip = true</code> before assigning, i.e. <code>markRaw</code>). But closure scope is simpler and I reach for it every time. Rule of thumb: <strong>only plain, serializable data goes on <code>x-data</code>.</strong></p>
<h2>Rule 3: <code>x-data</code> initializers <em>snapshot</em> — use <code>init</code>/<code>x-effect</code> for async</h2>
<p><code>x-data</code> runs once, eagerly, and takes whatever the expression evaluates to <em>right then</em>.</p>
<pre><code class="language-html">&lt;!-- ❌ `stats` is the Promise fetchStats() returned, not the resolved data --&gt;
&lt;div x-data="{ stats: fetchStats() }"&gt;
</code></pre>
<p>You'll see <code>[object Promise]</code> or stale/empty state and chase it for an hour. Load <em>then</em> assign:</p>
<pre><code class="language-html">&lt;div x-data="{ stats: null, async init() { this.stats = await fetchStats() } }"&gt;
  &lt;template x-if="stats"&gt;…&lt;/template&gt;
&lt;/div&gt;
</code></pre>
<p>Same shape bit me with theme switching: reading a value once at init captures it forever. If something needs to <em>react</em> to a change (a store value, a media query, a fetched result), it belongs in <code>x-effect</code> or an explicit assignment — not in the initializer expression.</p>
<h2>Rule 4: pass server data <em>as data</em>, not string interpolation</h2>
<p>The tempting thing is to jam Django context straight into an Alpine expression: <code>x-data="{ items: {{ items }} }"</code>. It works until a value contains a quote or a newline, and then you're back in Rule 1's parser hell. Use Django's <code>json_script</code>:</p>
<pre><code class="language-django">{{ items|json_script:"board-data" }}
</code></pre>
<pre><code class="language-html">&lt;div x-data="{ items: JSON.parse(document.getElementById('board-data').textContent) }"&gt;
</code></pre>
<p>Server renders JSON safely into a <code>&lt;script type="application/json"&gt;</code>; Alpine reads it. Clean separation: <strong>Django owns the data, the template just carries it, Alpine consumes it.</strong></p>
<h2>The small Django-template traps</h2>
<p>Two that bit me more than once:</p>
<ul>
<li><strong><code>{# … #}</code> is single-line only.</strong> A comment that wraps across lines renders as <em>literal visible text</em> — and if it's sitting next to an Alpine attribute, chaos. Use <code>{% comment %} … {% endcomment %}</code> for anything multi-line.</li>
<li><strong>Shared islands, single-sourced.</strong> The nav bar, the sidebar, the top bar — build each as <em>one</em> island partial you include everywhere, not a per-page fork. The day I stopped copy-pasting the nav component was the day the footer stopped mysteriously drifting between pages.</li>
</ul>
<h2>What "sane" ended up meaning</h2>
<p>Nothing exotic — just discipline:</p>
<ol>
<li><strong>Islands, not a SPA.</strong> Server owns data + page; Alpine owns a widget.</li>
<li><strong>Register non-trivial components</strong> in a JS file; inline only for toggles.</li>
<li><strong>Plain data only on <code>x-data</code></strong> — rich instances live in closures.</li>
<li><strong>Async loads via <code>init</code>/<code>x-effect</code></strong>, never the initializer expression.</li>
<li><strong>Server → client via <code>json_script</code></strong>, never string interpolation.</li>
<li>A <strong>browser smoke run</strong> as a release gate, because the worst Alpine bugs are HTML-parse issues that pass unit tests.</li>
</ol>
<p>Alpine scaled to a genuinely large app for me — but only once I stopped treating it like React-lite and started treating it like sprinkles on server-rendered HTML.</p>
<hr />
<p><em>I write these while building a free, full-lifecycle PM platform in the open — if the build-in-public stuff is your thing, the rest of the series is here.</em></p>
]]></content:encoded></item><item><title><![CDATA[Why I made my project-management SaaS 100% free (and how I plan to survive)]]></title><description><![CDATA[In my last post I wrote up what it cost to build VanillaPM — a full PMBOK project-management platform — solo in three months. The reaction was kind. But almost every reply circled the same question:

]]></description><link>https://vanillapm.hashnode.dev/why-i-made-my-project-management-saas-100-free-and-how-i-plan-to-survive</link><guid isPermaLink="true">https://vanillapm.hashnode.dev/why-i-made-my-project-management-saas-100-free-and-how-i-plan-to-survive</guid><category><![CDATA[buildinpublic]]></category><category><![CDATA[startup]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[indiehackers]]></category><dc:creator><![CDATA[Ahsan Ahmad]]></dc:creator><pubDate>Wed, 02 Sep 2026 06:25:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a968036a5b7111df9bb7964/c416eecd-963a-4a14-ad04-a867d75aa72b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In my last post I <a href="https://dev.to/vanillapm">wrote up what it cost to build VanillaPM</a> — a full PMBOK project-management platform — solo in three months. The reaction was kind. But almost every reply circled the same question:</p>
<blockquote>
<p>"It's genuinely free? <em>How are you going to survive?</em>"</p>
</blockquote>
<p>Fair question. Here's the honest answer — including the part where I admit I don't have it all figured out.</p>
<h2>First, what "free" actually means here</h2>
<p>Not a free trial. Not free-for-3-users-then-a-wall. The <strong>entire core is free</strong> — the full PMBOK 8th-edition lifecycle, charter to closeout, WBS, Gantt, Earned Value, risk registers, a knowledge base, sprints. No seat limits. No paywall on the thing itself. New accounts even open a fully-built sample project so you can use everything on real data before you create anything.</p>
<p>I did that on purpose, and it wasn't a growth hack.</p>
<h2>Why free — the real reason</h2>
<p>The tools that genuinely <em>teach</em> you project management are locked behind enterprise pricing. The ones you can actually afford don't teach you anything — they're glorified to-do lists. So the people who most need to <em>learn</em> how to run a project — a student, a freelancer, a first-time founder, a small NGO running on grants — are the exact people priced out of the tools that would teach them.</p>
<p>I wanted to close that gap. Free removes the friction for precisely the people who need it most. That's the whole point, and a paywall on the core would defeat it.</p>
<h2>The bigger bet</h2>
<p>Here's the part that reframes the "how will you survive" question: <strong>VanillaPM isn't "a free PM tool." It's the first module of a teaching business suite.</strong></p>
<p>Project management is the front door because it's where most people first hit the wall. But finance, HR, payroll and org design are already being built on the same spine — the idea being that a small org could eventually run its <em>whole</em> operation, and learn how to, in one place. Free PM is distribution and trust. The moat is the teaching and the breadth that follows.</p>
<h2>So… how do I plan to survive?</h2>
<p>Honestly? I have <em>directions</em>, not a locked plan — and I'd rather tell you that than pretend I have a tidy spreadsheet.</p>
<p>What I'm <strong>considering</strong> (none decided):</p>
<ul>
<li><p><strong>Managed AI, not manual AI.</strong> Bring-your-own-key AI is free. But hosting the AI — no key, no setup, it just works — is a real cost I could reasonably charge for. Convenience, not capability.</p>
</li>
<li><p><strong>Depth for organisations.</strong> The ERM finance-to-payroll suite, org-scoped access control at scale, multi-entity separation — the stuff a <em>company</em> needs, not a student. That's where willingness-to-pay actually lives.</p>
</li>
<li><p><strong>Hosted / support / SLA tiers</strong> for teams who want someone on the hook.</p>
</li>
<li><p><strong>The teaching layer</strong> — deeper courses, certifications-prep, guided tracks.</p>
</li>
</ul>
<p>What I will <strong>never</strong> do:</p>
<ul>
<li><p>Paywall the core PM lifecycle. It stays free.</p>
</li>
<li><p>Seat-limit the free tier into uselessness.</p>
</li>
<li><p>Sell your data. Ever.</p>
</li>
</ul>
<p>The principle I keep coming back to: <strong>money should come from depth and convenience, not from holding your project hostage.</strong> The free core is a promise, not a funnel with a trapdoor.</p>
<h2>The honest risk</h2>
<p>This might not work. "Free core, charge for depth" is a real model (it's how a lot of good software gets distributed), but plenty of founders have gone broke being generous. I know that.</p>
<p>I'm building in public partly <em>because</em> I don't have it fully solved — the feedback is the point. Free-first is a deliberate bet: distribution and trust now, revenue later, from the people and orgs who get enough value that paying feels obvious rather than extracted.</p>
<p>Ask me again in a year and I'll show you the numbers — the good and the bad. I've been sharing the flops too (my first ad: $3, some clicks, zero signups), and I'm not going to stop.</p>
<h2>Your turn</h2>
<p>If you've built or used a free-core product: <strong>how would <em>you</em> monetise this without betraying the "free" promise?</strong> Genuinely asking — the comments on posts like this have already changed my thinking more than any strategy deck.</p>
<p>And if you just want to poke at a free, full-lifecycle PM platform (no signup wall — there's a sample project waiting): it's at <a href="https://vanillapm.com"><strong>vanillapm.com</strong></a>.</p>
]]></content:encoded></item><item><title><![CDATA[I built a full PMBOK project-management platform solo in 3 months — here's what it cost]]></title><description><![CDATA[Three months ago, VanillaPM was an empty Django project. Today it runs the entire PMBOK 8th-edition project lifecycle — Initiating through Closing — with a work breakdown structure, critical-path sche]]></description><link>https://vanillapm.hashnode.dev/pmbok-platform-solo-3-months-cost</link><guid isPermaLink="true">https://vanillapm.hashnode.dev/pmbok-platform-solo-3-months-cost</guid><category><![CDATA[project management]]></category><category><![CDATA[Django]]></category><category><![CDATA[startup]]></category><category><![CDATA[Build In Public]]></category><dc:creator><![CDATA[Ahsan Ahmad]]></dc:creator><pubDate>Tue, 01 Sep 2026 09:41:39 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a968036a5b7111df9bb7964/938f93fd-7c52-4475-9ddc-36990b506a85.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Three months ago, VanillaPM was an empty Django project. Today it runs the entire <strong>PMBOK 8th-edition</strong> project lifecycle — Initiating through Closing — with a work breakdown structure, critical-path scheduling, Earned Value, risk and assumption registers, a governed knowledge base, sprints, an ERM finance-to-payroll suite, and a bring-your-own-key AI assistant. It's <strong>live</strong>, it has <strong>over 2,000 automated tests</strong>, and it's completely <strong>free</strong>.</p>
<p>I'm a developer of ~13 years who spent the last few leading delivery, and I wanted to genuinely <em>learn</em> formal project management — not from slides, but by building the tool I wished existed. This is the honest breakdown: what it cost in money, where the real leverage came from, and a few lessons that surprised me.</p>
<h2>What it actually cost</h2>
<table>
<thead>
<tr>
<th>Item</th>
<th>Cost</th>
</tr>
</thead>
<tbody><tr>
<td>VPS — 12 months, prepaid</td>
<td>$203.88</td>
</tr>
<tr>
<td>Business email — 48 months</td>
<td>$228.96</td>
</tr>
<tr>
<td>AI credits (the build phase)</td>
<td>$150.00</td>
</tr>
<tr>
<td><strong>One-time, to a shipped 1.0</strong></td>
<td><strong>$582.84</strong></td>
</tr>
<tr>
<td>Recurring during the build</td>
<td>~$46 / month</td>
</tr>
</tbody></table>
<p>Total to a shipped, tested, deployed 1.0: <strong>under $700.</strong> No team, no seed round, no office — a $17/month VPS, a mailbox, and some AI credits.</p>
<p>The line item that matters most is the smallest: that <strong>$150 of AI credits</strong> is the one that changed what "solo" even means.</p>
<h2>The real lever: AI as a force multiplier, not an autocomplete</h2>
<p>The interesting part isn't that AI wrote code. It's that it let <em>one person hold the whole system in their head at team throughput.</em> Scaffolding a new Django app, writing the tests alongside the feature, doing the third tedious refactor of the day, rubber-ducking an architecture decision, and — genuinely — teaching me the PMBOK concepts <em>while I built the features that implement them.</em></p>
<p>It is not magic, and it doesn't replace judgment. You still architect. You still decide. You still verify everything, because the model is confidently wrong often enough that trusting it blindly would sink you. But the throughput multiplier is real, and the shape of "what a solo developer can ship" has changed.</p>
<p>Some concrete numbers from the three months:</p>
<ul>
<li><strong>58 Django apps</strong></li>
<li><strong>~84,000 lines of Python</strong> (excluding migrations)</li>
<li><strong>320 migrations</strong> of schema evolution</li>
<li><strong>2,000+ tests</strong>, behind a CI gate that blocks any merge on a failing test or a coverage drop</li>
</ul>
<p>Every task got its own branch, ran tests + lint + a <code>makemigrations --check</code> on every change, and merged with <code>--no-ff</code>. That discipline is the thing AI <em>didn't</em> replace — it amplified it.</p>
<h2>Four lessons that surprised me</h2>
<p><strong>1. Tests are what make solo + AI safe.</strong> Those 2,000 tests aren't a vanity metric — they're the safety net that lets you refactor fearlessly and trust an AI-assisted change you didn't hand-write line by line. Without them, the speed becomes a liability. With them, it compounds.</p>
<p><strong>2. Building the PM tool taught me PM better than any course.</strong> I ran the build of VanillaPM <em>as a VanillaPM project</em> — a real charter, a WBS, sprints, formal change control on my own scope. Dogfooding turned abstract PMBOK concepts into muscle memory.</p>
<p><strong>3. The boring infrastructure decisions compound.</strong> Postgres end-to-end, object storage for files, a fail-closed search-indexing gate, automated backups, a health-checked deploy. None of it is exciting. All of it is why the thing stays up.</p>
<p><strong>4. Scope is the enemy, and formal change control on your <em>own</em> project keeps you honest.</strong> The number of "small" features I talked myself out of via my own change process is the reason 1.0 shipped at all.</p>
<h2>Why it's free</h2>
<p>The tools that actually teach you project management are locked behind enterprise pricing; the ones you can afford don't teach you anything. I wanted to close that gap — a place where a student, a freelancer, or a small NGO can run a <em>real</em> project (and learn how) without a paywall. New accounts even drop straight into a fully-built sample project — a 220-room hotel build with a real WBS, schedule, budget and risk register — so you can explore everything on real data before creating anything of your own.</p>
<p>There's also a free <strong>Learn PMP</strong> course and an <strong>ITTO memory game</strong> for exam prep, in seven languages.</p>
<h2>If you want to look</h2>
<p>It's at <strong><a href="https://vanillapm.com">vanillapm.com</a></strong> — free, no trial, no seat limits. I'm building the rest in public and I'll keep sharing the numbers, including the unflattering ones (my first paid ad: $3, a handful of clicks, zero signups — but that's another post).</p>
<p>If you're a developer curious about the solo-plus-AI workflow, or a PM tired of enterprise pricing, I'd genuinely love your feedback in the comments. What would you have built differently?</p>
]]></content:encoded></item></channel></rss>