<?xml version="1.0" encoding="UTF-8"?>
<feed xml:lang="en" xmlns="http://www.w3.org/2005/Atom">
  <id>tag:alvareznavarro.es,2005:/blog/feed</id>
  <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog"/>
  <link rel="self" type="application/atom+xml" href="https://alvareznavarro.es/blog/feed"/>
  <title>Jorge Alvarez - Blog</title>
  <subtitle>Articles about AI engineering and web development</subtitle>
  <updated>2026-04-14T21:17:26Z</updated>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/198</id>
    <published>2026-04-14T00:00:00Z</published>
    <updated>2026-04-14T21:17:26Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2026/04/ai-made-code-and-design-easy-conversion-is-still-the-hard-part"/>
    <title>AI made code and design easy. Conversion is still the hard part.</title>
    <content type="html">&lt;p&gt;AI has made two things trivial: writing the code behind a website, and designing how it looks. Both used to be jobs. Now they're prompts.&lt;/p&gt;
&lt;p&gt;That sounds like the end of the web as a craft. It isn't. It just means the craft moved.&lt;/p&gt;
&lt;p&gt;The part that still matters — the part AI can't do for you — is building a site that converts.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#code-and-design-are-both-commodities-now" aria-hidden="true" class="anchor" id="code-and-design-are-both-commodities-now"&gt;&lt;/a&gt;Code and design are both commodities now&lt;/h2&gt;
&lt;p&gt;A year ago, &amp;quot;I need a website&amp;quot; meant hiring a developer, a designer, or both. Today, Squarespace, v0, Lovable, or Claude Code will hand you a clean, professional site from a prompt in an afternoon. The HTML is fine. The layout is fine. The typography is fine.&lt;/p&gt;
&lt;p&gt;It's also completely interchangeable. Same hero, same three-column features block, same stock photography, same CTA. When everyone's site looks the same and ships in the same week, neither code nor design is a differentiator anymore.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If it can be generated, it can't be your edge.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;And the traffic math has shifted underneath all of it. Nearly &lt;a href="https://www.forbes.com/councils/forbesbusinesscouncil/2026/03/02/the-zero-click-economy-why-60-of-searches-end-without-a-click-and-what-ceos-should-do-about-it/"&gt;60% of Google searches now end with zero clicks&lt;/a&gt;, and AI overviews drop organic click-through by as much as 61%. Fewer people are visiting, and the ones who do arrive with sharper intent. That makes every visit more expensive and more valuable — which means the site has to do real work when someone shows up.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#conversion-is-the-part-ai-cant-automate" aria-hidden="true" class="anchor" id="conversion-is-the-part-ai-cant-automate"&gt;&lt;/a&gt;Conversion is the part AI can't automate&lt;/h2&gt;
&lt;p&gt;Conversion isn't a visual problem. It's a thinking problem.&lt;/p&gt;
&lt;p&gt;It's knowing who your customer actually is — not the persona, the real one, the one who opens your site at 11pm with a specific worry. It's knowing which objection kills the deal and answering it before they ask. It's knowing why people who land on your pricing page bounce, and whether the fix is copy, proof, price, or the offer itself.&lt;/p&gt;
&lt;p&gt;AI can't do any of that for you. It doesn't know your business model. It doesn't know your customer's budget cycle. It can't look at a site that gets traffic but doesn't sign anyone up and tell you why. It'll happily generate another hero section, but the hero section was never the problem.&lt;/p&gt;
&lt;p&gt;The code is the easy part now. The design is the easy part now. &lt;strong&gt;The thinking is the work.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;&lt;a href="#change-the-question-you-ask-about-your-site" aria-hidden="true" class="anchor" id="change-the-question-you-ask-about-your-site"&gt;&lt;/a&gt;Change the question you ask about your site&lt;/h2&gt;
&lt;p&gt;Stop asking &amp;quot;does it look good?&amp;quot; Start asking &amp;quot;is this bringing me customers next month?&amp;quot;&lt;/p&gt;
&lt;p&gt;Here's a useful test: if an AI could answer every question your site tries to answer, would anyone still need to visit? If the answer is no, the site is doing the wrong job — and a prettier hero section won't fix it. A cleaner codebase won't fix it either.&lt;/p&gt;
&lt;p&gt;Measure the site against the outcome you actually care about: leads, signups, contacts, revenue. That's the part no AI can replace, and that's the part worth paying for.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#summary" aria-hidden="true" class="anchor" id="summary"&gt;&lt;/a&gt;Summary&lt;/h2&gt;
&lt;p&gt;AI took code and design off the critical path. Anyone can ship a good-looking site in an afternoon. What AI can't do is figure out who your customer is, what stops them from buying, and how to fix it. That's conversion — and &lt;a href="https://youtu.be/6JHQK-lG79g?si=I4IoJ7ZCwwqC4oLA"&gt;conversion is where the work lives now&lt;/a&gt;.&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="business"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/197</id>
    <published>2026-04-12T00:00:00Z</published>
    <updated>2026-04-12T16:57:43Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2026/04/we-need-more-open-source-ruby-on-rails-projects"/>
    <title>We need more open source Ruby on Rails projects</title>
    <content type="html">&lt;p&gt;I asked Claude a simple question: &lt;em&gt;&amp;quot;If you were working on a web project, which language and framework would you choose and why? Don't think about me as a developer -- think about yourself as an LLM.&amp;quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;It did not pick Rails.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-llms-honest-answer" aria-hidden="true" class="anchor" id="the-llms-honest-answer"&gt;&lt;/a&gt;The LLM's honest answer&lt;/h2&gt;
&lt;p&gt;Claude picked Python and TypeScript. Not because they are better languages -- but because its code generation quality is directly proportional to how many high-quality examples exist in its training data.&lt;/p&gt;
&lt;p&gt;Python and TypeScript dominate open source repositories, Stack Overflow, tutorials, and documentation. When an LLM writes Python or TypeScript, it draws from an enormously deep well of patterns. It makes fewer mistakes, knows more edge cases, and generates idiomatic code more reliably.&lt;/p&gt;
&lt;p&gt;About Rails, Claude said something that should concern every Ruby developer: &lt;em&gt;&amp;quot;I can write solid Rails code, but I'm more likely to hallucinate a method name, use a deprecated API, or miss a Rails 7+ convention. The gap isn't huge, but it's real.&amp;quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I can confirm this from my own experience. I use Claude Code every day to build Rails applications -- this blog, my daily work, side projects. The AI is good at Rails. But it is noticeably better at TypeScript. It hallucinates less, catches more edge cases, and writes more idiomatic code in languages where the training data is deeper.&lt;/p&gt;
&lt;p&gt;What makes LLMs most productive is not the language itself -- it is how well-typed and explicit the code is. TypeScript beats JavaScript for LLMs because type annotations act as self-documenting constraints. Convention-heavy frameworks like Rails, with all their implicit magic, are paradoxically harder for AI because there is more context the model needs to get right from memory rather than from explicit structure in the code.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#i-see-this-every-day" aria-hidden="true" class="anchor" id="i-see-this-every-day"&gt;&lt;/a&gt;I see this every day&lt;/h2&gt;
&lt;p&gt;Ruby and Rails were designed with a clear philosophy: optimize for developer happiness. Convention over configuration. Don't Repeat Yourself. Make the developer's life easier.&lt;/p&gt;
&lt;p&gt;That philosophy was perfect for a world where humans wrote every line of code. But that world is changing fast.&lt;/p&gt;
&lt;p&gt;I build with Claude Code daily. I watch it write controllers, models, tests, service objects. A growing percentage of production code -- in my projects and everywhere else -- is being written by AI agents. Claude Code, GitHub Copilot, Cursor, Codex, and dozens of other tools are becoming primary authors of code while developers shift toward reviewing, directing, and architecting.&lt;/p&gt;
&lt;p&gt;In this new reality, developer happiness is necessary but no longer sufficient. We also need to think about AI fluency -- how well can an LLM understand, generate, and reason about code in a given ecosystem?&lt;/p&gt;
&lt;p&gt;Right now, Ruby on Rails is falling behind on that metric. Not because of any technical shortcoming, but because of a training data gap.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-training-data-problem" aria-hidden="true" class="anchor" id="the-training-data-problem"&gt;&lt;/a&gt;The training data problem&lt;/h2&gt;
&lt;p&gt;LLMs learn from what they have seen. The more high-quality, diverse, well-documented open source projects exist in a given language, the better the LLM performs with that language. It is that simple.&lt;/p&gt;
&lt;p&gt;Look at the JavaScript and Python ecosystems. Thousands upon thousands of open source projects, tutorials, blog posts, and examples. Every possible pattern, every edge case, every architectural decision -- documented and available for training.&lt;/p&gt;
&lt;p&gt;Now look at Ruby on Rails. We have some excellent open source projects -- &lt;a href="https://github.com/discourse/discourse"&gt;Discourse&lt;/a&gt;, &lt;a href="https://github.com/mastodon/mastodon"&gt;Mastodon&lt;/a&gt;, &lt;a href="https://gitlab.com/gitlab-org/gitlab"&gt;GitLab&lt;/a&gt;, &lt;a href="https://github.com/solidusio/solidus"&gt;Solidus&lt;/a&gt;. More recently, 37signals made a significant contribution by open sourcing their ONCE products: &lt;a href="https://once.com/campfire"&gt;Campfire&lt;/a&gt; (group chat), &lt;a href="https://once.com/writebook"&gt;Writebook&lt;/a&gt; (online book publishing), and &lt;a href="https://www.fizzy.do/"&gt;Fizzy&lt;/a&gt; (kanban tracking). These are production-grade Rails applications built by the creators of the framework itself -- exactly the kind of high-quality, real-world codebases that LLMs need to learn from.&lt;/p&gt;
&lt;p&gt;But even with these additions, the volume does not compare to what exists in JavaScript or Python. Too many Rails applications remain proprietary, behind closed doors, invisible to training datasets.&lt;/p&gt;
&lt;p&gt;This creates a vicious cycle: LLMs perform worse with Rails, so developers building with AI choose other stacks, so fewer new Rails projects get created, so even less training data exists, so LLMs fall further behind.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#rails-magic-becomes-an-ai-obstacle" aria-hidden="true" class="anchor" id="rails-magic-becomes-an-ai-obstacle"&gt;&lt;/a&gt;Rails magic becomes an AI obstacle&lt;/h2&gt;
&lt;p&gt;The very things that make Rails beautiful for human developers make it harder for AI agents.&lt;/p&gt;
&lt;p&gt;Convention over configuration means a lot of implicit knowledge is required. A human Rails developer &lt;em&gt;knows&lt;/em&gt; that a model called &lt;code&gt;User&lt;/code&gt; maps to a &lt;code&gt;users&lt;/code&gt; table, that &lt;code&gt;has_many :posts&lt;/code&gt; creates a whole set of methods, that &lt;code&gt;before_action&lt;/code&gt; callbacks fire in a specific order. This implicit knowledge is what makes Rails so productive for experienced developers.&lt;/p&gt;
&lt;p&gt;But for an LLM, every implicit convention is a potential hallucination point. There is no type signature to anchor to, no explicit declaration to reference. The model has to recall the convention from its training data -- and if that training data is thin, it gets things wrong.&lt;/p&gt;
&lt;p&gt;I hit this regularly. Claude Code will generate a method call that looks right but does not exist. It will use a Rails 6 pattern in a Rails 8 app. It will miss a convention that any experienced Rails developer would know instinctively. The model is not stupid -- it just has less to draw from.&lt;/p&gt;
&lt;p&gt;TypeScript, by contrast, spells everything out. The types are right there in the code. An LLM does not need to remember conventions -- it can read the constraints directly.&lt;/p&gt;
&lt;p&gt;This does not mean Rails needs to become TypeScript. But it does mean the Rails community needs to compensate for this structural disadvantage by flooding the training pipeline with high-quality examples.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#what-i-am-doing-about-it----and-what-you-can-do-too" aria-hidden="true" class="anchor" id="what-i-am-doing-about-it----and-what-you-can-do-too"&gt;&lt;/a&gt;What I am doing about it -- and what you can do too&lt;/h2&gt;
&lt;p&gt;I am not going to write a list of things &amp;quot;we as a community should do&amp;quot; and leave it at that. Here is what I am actually doing, and what I think you should consider.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I open source my work.&lt;/strong&gt; This blog is a Rails 8.1 application. I have many Ruby on Rails projects in my public &lt;a href="https://github.com/jorgegorka"&gt;github repository&lt;/a&gt;. Every controller, every model, every test -- it is all training data. If you have a Rails application, even a small one, even an imperfect one, consider open sourcing it. Every public repository helps.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I write about Rails and AI.&lt;/strong&gt; You are reading this. Blog posts, tutorials, guides -- they all end up in training datasets. I write about how I use Claude Code with Rails, how I integrate LLMs into my SaaS products, what patterns work in production. Every piece of public Rails content makes AI a little bit better at our stack. Write about how you solved that tricky Active Record query. Document your Hotwire patterns. Explain your Turbo Stream architecture.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I document conventions explicitly.&lt;/strong&gt; The Rails guides are good, but we need more. When I write a tutorial, I spell out the things that experienced Rails developers take for granted. Instead of relying on &amp;quot;every Rails developer knows this,&amp;quot; I write it down. LLMs benefit from explicit documentation of patterns and conventions that humans learn through years of experience. It might sound counterintuitive, but we should write content thinking that LLMs will read it and learn from it so they can teach the developers that use those LLMs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Build reference applications.&lt;/strong&gt; The Rails community would benefit enormously from a set of well-maintained, well-documented reference applications covering common patterns -- multi-tenancy, API design, real-time features with Hotwire, background job architectures, authentication flows. Not toy examples -- production-grade references. 37signals started this with ONCE. We need more.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Contribute to Rails itself.&lt;/strong&gt; The framework's own codebase, tests, and documentation are prime training material. Better inline documentation, more descriptive commit messages, clearer test names -- all of this helps LLMs understand Rails conventions.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#this-matters-more-than-you-think" aria-hidden="true" class="anchor" id="this-matters-more-than-you-think"&gt;&lt;/a&gt;This matters more than you think&lt;/h2&gt;
&lt;p&gt;If LLMs consistently perform better with Python and TypeScript than with Ruby, the next generation of developers -- who increasingly rely on AI assistance -- will naturally gravitate away from Rails. Not because Rails is worse, but because their AI tools work better with other stacks.&lt;/p&gt;
&lt;p&gt;I have been building SaaS products with Rails for 20 years. I have watched adoption cycles come and go. When Stack Overflow became the de facto knowledge base, languages with more Stack Overflow coverage gained an adoption advantage. Training data is the new Stack Overflow.&lt;/p&gt;
&lt;p&gt;Ruby on Rails gave us an era of unmatched developer productivity. It showed the world that web development could be joyful. That philosophy still matters -- arguably more than ever in a world drowning in over-engineered complexity.&lt;/p&gt;
&lt;p&gt;I want to make sure that when an AI agent sits down to write code, it can write Rails as fluently as it writes Next.js. And that starts with us giving those AI agents something to learn from.&lt;/p&gt;
&lt;p&gt;Open source your projects. Write about your work. Document your patterns. The future of Rails depends on it.&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="AI Engineering"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/196</id>
    <published>2026-03-26T00:00:00Z</published>
    <updated>2026-03-26T22:52:28Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2026/03/building-tools-for-ai-agents-what-i-ve-learned-working-with-claude-code"/>
    <title>Building Tools for AI Agents: What I've Learned Working with Claude Code</title>
    <content type="html">&lt;p&gt;Most developers using &lt;a href="https://code.claude.com/docs/en/overview"&gt;Claude Code&lt;/a&gt; treat it as a very smart autocomplete. They type a prompt, get some code, and move on. But the real power unlock happens when you start building &lt;em&gt;tools&lt;/em&gt; for the agent — extending what it can do rather than just asking it to write code for you.&lt;/p&gt;
&lt;p&gt;I've been spending a lot of time lately thinking about this, especially after reading &lt;a href="https://www.anthropic.com/engineering/writing-tools-for-agents"&gt;Anthropic's engineering blog on writing effective tools for agents&lt;/a&gt;. Building tools for AI agents requires fundamentally rethinking how we design software interfaces. You're not writing an API for a human developer anymore. You're writing one for a non-deterministic system that reads documentation, makes decisions, and occasionally gets confused.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#agents-dont-use-tools-the-way-you-do" aria-hidden="true" class="anchor" id="agents-dont-use-tools-the-way-you-do"&gt;&lt;/a&gt;Agents don't use tools the way you do&lt;/h2&gt;
&lt;p&gt;When I build a REST API or a Ruby gem, I think about the developer who will read the docs, understand the data model, and write integration code. That developer has state in their head — they remember what they did three API calls ago.&lt;/p&gt;
&lt;p&gt;An AI agent doesn't work that way. It operates within a context window, and every token it reads is one less token it has available for reasoning. Anthropic's team illustrates this well: imagine asking an agent to look up a contact in an address book. A traditional program would iterate through entries efficiently. But an agent has to &lt;em&gt;read&lt;/em&gt; each entry token by token, burning through its context window on irrelevant data.&lt;/p&gt;
&lt;p&gt;If your tool returns a 500-line JSON response when the agent only needs three fields, you're wasting the agent's most precious resource: attention.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-mcp-ecosystem-changed-everything" aria-hidden="true" class="anchor" id="the-mcp-ecosystem-changed-everything"&gt;&lt;/a&gt;The MCP ecosystem changed everything&lt;/h2&gt;
&lt;p&gt;The &lt;a href="https://modelcontextprotocol.io"&gt;Model Context Protocol (MCP)&lt;/a&gt;, which Anthropic open-sourced in late 2024, has become the standard for connecting AI agents to external tools. Since then, OpenAI adopted it across ChatGPT, Google confirmed support for Gemini, and the protocol was donated to the Linux Foundation's Agentic AI Foundation in December 2025. It's no longer an Anthropic thing — it's an industry thing.&lt;/p&gt;
&lt;p&gt;For Claude Code specifically, &lt;a href="https://code.claude.com/docs/en/mcp"&gt;MCP servers&lt;/a&gt; are how you give the agent access to your databases, APIs, internal tools, and custom workflows. There are two main transport types: HTTP servers for remote/cloud services and stdio servers for local processes that need direct system access.&lt;/p&gt;
&lt;p&gt;There are MCP servers for GitHub, PostgreSQL, file systems, and hundreds more. And here's where it gets interesting for us as developers: you can build your own.&lt;/p&gt;
&lt;p&gt;In fact, this very blog runs on a custom MCP server. It exposes a handful of tools — &lt;code&gt;create_article&lt;/code&gt;, &lt;code&gt;publish_article&lt;/code&gt;, &lt;code&gt;list_categories&lt;/code&gt;, &lt;code&gt;search_articles&lt;/code&gt; — that let Claude Code manage the entire content workflow. This post you're reading right now? I wrote the draft, asked Claude to add documentation links and publish it, and it did — creating the article, assigning the category, and setting the publication date, all through MCP tool calls. No admin panel, no browser, no copy-pasting into a CMS. Just a conversation in the terminal.&lt;/p&gt;
&lt;p&gt;That's a concrete example of what I mean by building tools for the agent rather than asking it to write code for you. The MCP server for this blog is a small Rails API — about 7 endpoints — but it turns Claude Code into a full publishing assistant.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#fewer-tools-smarter-tools" aria-hidden="true" class="anchor" id="fewer-tools-smarter-tools"&gt;&lt;/a&gt;Fewer tools, smarter tools&lt;/h2&gt;
&lt;p&gt;One of the most counterintuitive lessons from working with agent tooling is that &lt;em&gt;more tools is not better&lt;/em&gt;. Anthropic's engineering team recommends implementing fewer, more thoughtful tools that target high-impact workflows rather than wrapping every API endpoint.&lt;/p&gt;
&lt;p&gt;Think about it from the agent's perspective. Every tool you expose adds to the decision space. If you give an agent 50 narrow tools, it has to figure out which combination to use and in what order. If you give it 10 well-designed tools that handle common workflows end-to-end, it can get things done in fewer steps with less room for error.&lt;/p&gt;
&lt;p&gt;This is something I've experienced firsthand building tools. My first instinct was to create granular tools — one to list users, another to fetch a specific user, another to update a field. But consolidating those into higher-level operations like &lt;code&gt;find_and_update_user&lt;/code&gt; or &lt;code&gt;generate_user_report&lt;/code&gt; made the agent dramatically more effective.&lt;/p&gt;
&lt;p&gt;It's the same principle behind fat models in Rails: push the complexity down into the tool so the consumer (in this case, the agent) can stay focused on the task.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#how-to-write-tool-descriptions-that-actually-work" aria-hidden="true" class="anchor" id="how-to-write-tool-descriptions-that-actually-work"&gt;&lt;/a&gt;How to write tool descriptions that actually work&lt;/h2&gt;
&lt;p&gt;This might be the most underrated aspect of building agent tools. The description you give a tool is not just documentation — it's the primary interface the agent uses to decide &lt;em&gt;when&lt;/em&gt; and &lt;em&gt;how&lt;/em&gt; to call it.&lt;/p&gt;
&lt;p&gt;Anthropic's recommendation is to describe tools as you would explain them to a new team member. Make implicit assumptions explicit. Use unambiguous parameter names (&lt;code&gt;user_id&lt;/code&gt; instead of just &lt;code&gt;user&lt;/code&gt;). Include what the tool does, when to use it, and what it returns.&lt;/p&gt;
&lt;p&gt;I've found that even small changes to tool descriptions can have outsized effects on agent performance. Adding a single sentence like &amp;quot;Use this tool when you need to check if a deploy is safe to proceed&amp;quot; to a deployment tool's description cut misuse of that tool by the agent significantly.&lt;/p&gt;
&lt;p&gt;Here's a pattern I follow for my tool descriptions:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What it does&lt;/strong&gt; — A single sentence explaining the tool's purpose.&lt;br /&gt;
&lt;strong&gt;When to use it&lt;/strong&gt; — Specific scenarios where this tool is the right choice.&lt;br /&gt;
&lt;strong&gt;What it returns&lt;/strong&gt; — The shape and meaning of the response.&lt;br /&gt;
&lt;strong&gt;What it does NOT do&lt;/strong&gt; — Boundaries to prevent misuse.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#claudemd-is-your-projects-instruction-manual-for-the-agent" aria-hidden="true" class="anchor" id="claudemd-is-your-projects-instruction-manual-for-the-agent"&gt;&lt;/a&gt;CLAUDE.md is your project's instruction manual for the agent&lt;/h2&gt;
&lt;p&gt;If you're using Claude Code and haven't set up a &lt;a href="https://code.claude.com/docs/en/memory"&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; file&lt;/a&gt; in your project root, you're leaving a lot of value on the table. This file is essentially a briefing document that Claude reads every time it starts working on your codebase. It prevents the agent from having to rediscover your build commands, test runners, architecture patterns, and conventions every session.&lt;/p&gt;
&lt;p&gt;In this blog's Rails project at alvareznavarro.es, the &lt;code&gt;CLAUDE.md&lt;/code&gt; file includes the development commands (&lt;code&gt;bin/dev&lt;/code&gt;, &lt;code&gt;bin/rails test&lt;/code&gt;, &lt;code&gt;bin/rubocop&lt;/code&gt;), the site structure, authentication approach, model relationships, and infrastructure details. It's not long — maybe 80 lines — but it saves an enormous amount of time and makes the agent's output far more consistent.&lt;/p&gt;
&lt;p&gt;Think of it as an onboarding document for an extremely fast but forgetful developer who joins your team fresh every single day.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#custom-slash-commands-and-skills" aria-hidden="true" class="anchor" id="custom-slash-commands-and-skills"&gt;&lt;/a&gt;Custom slash commands and skills&lt;/h2&gt;
&lt;p&gt;Beyond MCP servers, Claude Code supports &lt;a href="https://code.claude.com/docs/en/skills"&gt;custom slash commands and skills&lt;/a&gt; through &lt;code&gt;.claude/commands/&lt;/code&gt; and &lt;code&gt;.claude/skills/&lt;/code&gt; directories. These are essentially prompt templates written in Markdown that you can invoke during a session.&lt;/p&gt;
&lt;p&gt;The beauty of this approach is its simplicity. You write a Markdown file with natural language instructions, drop it in the right folder, and suddenly your agent has a new capability. Need a command that generates a migration following your team's conventions? Write a &lt;code&gt;.claude/commands/generate-migration.md&lt;/code&gt; file that describes exactly how you want it done.&lt;/p&gt;
&lt;p&gt;Skills add an extra layer with YAML frontmatter that controls when Claude automatically invokes them. This means you can create tools that trigger based on context — the agent recognizes when a skill is relevant and uses it without you having to explicitly call it.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#design-for-token-efficiency" aria-hidden="true" class="anchor" id="design-for-token-efficiency"&gt;&lt;/a&gt;Design for token efficiency&lt;/h2&gt;
&lt;p&gt;Every response your tool generates costs tokens, and tokens cost both money and, more importantly, context space. Some practical guidelines:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Return only what's needed.&lt;/strong&gt; If your tool fetches user data but the agent only needs the name and email for the current task, consider offering a &lt;code&gt;response_format&lt;/code&gt; parameter that lets the agent choose between a concise and a detailed response.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use meaningful identifiers.&lt;/strong&gt; Return &lt;code&gt;jorge-alvarez&lt;/code&gt; instead of &lt;code&gt;a1b2c3d4-e5f6-7890-abcd-ef1234567890&lt;/code&gt;. When the agent needs to reference this entity later, a human-readable slug is far less likely to cause errors than a UUID the agent has to copy perfectly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Implement sensible defaults for pagination.&lt;/strong&gt; Don't return 10,000 records when the agent probably needs the first 20. Anthropic suggests capping responses at around 25,000 tokens for Claude Code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Make errors actionable.&lt;/strong&gt; Instead of returning &lt;code&gt;Error 422&lt;/code&gt;, return &lt;code&gt;&amp;quot;The user email is already taken. Try searching for the existing user with find_user(email='...')&amp;quot;&lt;/code&gt;. Give the agent a path forward.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-eval-loop-how-to-actually-improve-your-tools" aria-hidden="true" class="anchor" id="the-eval-loop-how-to-actually-improve-your-tools"&gt;&lt;/a&gt;The eval loop: how to actually improve your tools&lt;/h2&gt;
&lt;p&gt;One thing that struck me from Anthropic's approach is how seriously they take evaluation. They don't just build a tool and ship it. They generate realistic test scenarios, run them programmatically, track metrics beyond simple accuracy (runtime, token consumption, error rates), and iterate.&lt;/p&gt;
&lt;p&gt;You can adopt a lighter version of this. After building a tool, try using it with Claude Code on real tasks from your daily work. Pay attention to when the agent picks the wrong tool, when it calls a tool with incorrect parameters, and when it asks for clarification it shouldn't need. Each of these is a signal that your tool's interface needs refinement.&lt;/p&gt;
&lt;p&gt;A structured approach to measuring tool quality — even if it's just a spreadsheet tracking success rates across a dozen test prompts — will teach you more about agent-tool interaction than any blog post (including this one).&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-bigger-picture" aria-hidden="true" class="anchor" id="the-bigger-picture"&gt;&lt;/a&gt;The bigger picture&lt;/h2&gt;
&lt;p&gt;One analysis of open-source pull requests found that structured AI development with MCP servers and project-scoped configuration produced significantly fewer defects and security vulnerabilities compared to ad-hoc approaches. The exact numbers will vary across teams, but the direction is clear: giving agents well-designed tools leads to better outcomes than just letting them freestyle.&lt;/p&gt;
&lt;p&gt;We're moving from a world where AI assists with code completion to one where AI agents orchestrate entire development workflows. The developers who learn to build good tools for these agents will have a significant advantage — not because the tools are hard to build, but because the design thinking required is genuinely different from what most of us are used to.&lt;/p&gt;
&lt;p&gt;The good news is that if you already care about clean API design, good documentation, and thoughtful abstractions, you're most of the way there. The shift is in empathy: instead of designing for a human developer reading your docs, you're designing for an agent that reads your descriptions, makes decisions based on them, and occasionally needs to be steered back on track.&lt;/p&gt;
&lt;p&gt;Start small. Pick one repetitive workflow in your development process, build an MCP server or a custom slash command for it, and see how Claude Code handles it. Iterate from there. That's how I started — a simple MCP server so I could publish blog posts from the terminal — and it's changed how I think about developer tooling entirely.&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="AI Engineering"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/195</id>
    <published>2026-03-23T00:00:00Z</published>
    <updated>2026-03-23T08:40:43Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2026/03/how-i-built-a-complete-analytics-dashboard-for-my-rails-blog-with-claude-code"/>
    <title>How I built a complete analytics dashboard for my Rails blog with Claude Code</title>
    <content type="html">&lt;p&gt;Most developers reach for Google Analytics without thinking. It's free, it's everywhere, and you already have an account. But you're running Rails -- you have a database, a server, and a framework that's very good at building things. Why hand your visitors' data to Google when you can own every line of the analytics code yourself?&lt;/p&gt;
&lt;p&gt;I built a full analytics dashboard for this blog: unique visitors, page views, bounce rate, visit duration, traffic trends with SVG charts, geolocation by country, referral breakdowns, period-over-period comparison. It's public -- go look at it at &lt;a href="/analytics"&gt;/analytics&lt;/a&gt;. Every metric, every chart, every table. No third-party scripts, no cookie banners, no data leaving my server.&lt;/p&gt;
&lt;p&gt;Here's exactly how I built it with Claude Code as my AI pair programmer.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#why-build-your-own-analytics" aria-hidden="true" class="anchor" id="why-build-your-own-analytics"&gt;&lt;/a&gt;Why build your own analytics&lt;/h2&gt;
&lt;p&gt;Four reasons, in order of importance:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Privacy.&lt;/strong&gt; No third-party JavaScript tracking your visitors across the web. No cookie consent banners. No GDPR headaches beyond what you'd already have. Your visitors' data stays in your database, on your server.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ownership.&lt;/strong&gt; When Google decides to change their dashboard, deprecate a metric, or sunset Universal Analytics (again), you're not affected. Your data, your queries, your rules.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Customization.&lt;/strong&gt; Google Analytics shows you what Google thinks matters. I wanted to see exactly the metrics relevant to a solo blog -- top pages, referral domains classified by type (social, search, other), period comparison badges, and a clean SVG trend chart. No funnel analysis, no e-commerce tracking, no noise.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simplicity.&lt;/strong&gt; One gem for tracking. One controller for queries. One view for display. That's the entire analytics stack. Compare that to learning GA4's event model, tag manager, data streams, and property configuration.&lt;/p&gt;
&lt;p&gt;The alternatives like Plausible, are good products. But they cost money, run on someone else's infrastructure, and still mean you don't own the code. For a personal blog, building it yourself takes less time than evaluating SaaS options.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-tools" aria-hidden="true" class="anchor" id="the-tools"&gt;&lt;/a&gt;The tools&lt;/h2&gt;
&lt;p&gt;Three pieces make this work:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ahoy Matey&lt;/strong&gt; handles page view tracking. It's a &lt;a href="https://github.com/ankane/ahoy"&gt;Rails gem that records visits and events&lt;/a&gt; server-side -- no JavaScript tracker needed, no cookies. It creates two tables (&lt;code&gt;ahoy_visits&lt;/code&gt; and &lt;code&gt;ahoy_events&lt;/code&gt;) and gives you ActiveRecord models to query them. Configure it to run without cookies and you get privacy-friendly tracking with zero client-side code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Geocoder + MaxMindDB&lt;/strong&gt; handle IP geolocation. Geocoder is the standard Ruby geocoding gem. Pair it with a local MaxMind GeoLite2-City database file and every IP lookup happens on your server -- no external API calls, no rate limits, no latency. Ahoy integrates with Geocoder automatically if you tell it to.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Claude Code + Ariadna&lt;/strong&gt; is how I actually built it. Claude Code is Anthropic's CLI tool for AI pair programming -- you describe what you want, it reads your codebase, and writes the code. &lt;a href="https://github.com/jorgegorka/ariadna"&gt;Ariadna is a Claude Code plugin that adds structured planning on top&lt;/a&gt;. Instead of going straight from idea to code, Ariadna introduces a workflow: write a design spec that describes &lt;em&gt;what&lt;/em&gt; to build and &lt;em&gt;why&lt;/em&gt;, generate an implementation plan with specific tasks, file paths, and commit messages, then execute task by task with clean atomic commits. Think of it as adding a planning layer between &amp;quot;I want analytics&amp;quot; and &amp;quot;here's the code.&amp;quot;&lt;/p&gt;
&lt;p&gt;The Ariadna workflow for the analytics dashboard looked like this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Design spec&lt;/strong&gt; -- I described the dashboard I wanted: KPI cards, SVG charts, period comparison, geolocation. I specified Ahoy for tracking, no JavaScript charting libraries, public access without authentication.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Implementation plan&lt;/strong&gt; -- Ariadna broke the spec into ordered tasks: install and configure Ahoy, create the Trackable concern, build the analytics controller with metric calculations, build the view with SVG charts, add geolocation, write tests.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Execution&lt;/strong&gt; -- Claude Code followed the plan task by task. I reviewed each commit before moving to the next. If something needed adjustment, I said so and Claude Code corrected it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The design spec was around 200 lines. The implementation plan was over 800. The resulting code was clean, tested, and shipped in one session.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#tracking-page-views-automatically" aria-hidden="true" class="anchor" id="tracking-page-views-automatically"&gt;&lt;/a&gt;Tracking page views automatically&lt;/h2&gt;
&lt;p&gt;The first thing you need is a way to record every page view without touching every controller action. A Rails concern handles this perfectly.&lt;/p&gt;
&lt;pre lang="ruby" style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#65737e;"&gt;# app/controllers/concerns/trackable.rb
&lt;/span&gt;&lt;span style="color:#b48ead;"&gt;module &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;Trackable
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#8fa1b3;"&gt;extend &lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;ActiveSupport&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;::Concern
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#96b5b4;"&gt;included &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;do
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    after_action &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;:track_page_view
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#8fa1b3;"&gt;private
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;def &lt;/span&gt;&lt;span style="color:#8fa1b3;"&gt;track_page_view
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;return unless&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; trackable_request?
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    ahoy.track &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;Page View&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;, &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;url:&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; request.path
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;def &lt;/span&gt;&lt;span style="color:#8fa1b3;"&gt;trackable_request?
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    request.get? &amp;amp;&amp;amp;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;      request.&lt;/span&gt;&lt;span style="color:#96b5b4;"&gt;format&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.html? &amp;amp;&amp;amp;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;      response.successful? &amp;amp;&amp;amp;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;      !request.path.start_with?(&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;/admin&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;, &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;/login&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;, &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;/session&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;, &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;/up&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;, &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;/mcp&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Include this in &lt;code&gt;ApplicationController&lt;/code&gt; and every public page request is tracked automatically. The &lt;code&gt;trackable_request?&lt;/code&gt; method filters out the noise -- admin pages, login attempts, health checks, API endpoints. Only successful GET requests for HTML pages get recorded.&lt;/p&gt;
&lt;p&gt;The Ahoy configuration is equally minimal:&lt;/p&gt;
&lt;pre lang="ruby" style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#65737e;"&gt;# config/initializers/ahoy.rb
&lt;/span&gt;&lt;span style="color:#b48ead;"&gt;class &lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Ahoy::Store &lt;/span&gt;&lt;span style="color:#eff1f5;"&gt;&amp;lt; &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;Ahoy::DatabaseStore
&lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Ahoy&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.api = &lt;/span&gt;&lt;span style="color:#d08770;"&gt;false
&lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Ahoy&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.cookies = &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;:none
&lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Ahoy&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.server_side_visits = &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;:when_needed
&lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Ahoy&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.visit_duration = &lt;/span&gt;&lt;span style="color:#d08770;"&gt;30&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.minutes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three lines that matter: &lt;code&gt;api: false&lt;/code&gt; disables the JavaScript API (we don't need it), &lt;code&gt;cookies: :none&lt;/code&gt; means no cookies are set on the visitor's browser, and &lt;code&gt;server_side_visits: :when_needed&lt;/code&gt; creates visit records on the server side. Zero JavaScript. Zero cookies. Every page view is tracked by a single &lt;code&gt;after_action&lt;/code&gt; callback.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-analytics-controller----computing-real-metrics" aria-hidden="true" class="anchor" id="the-analytics-controller----computing-real-metrics"&gt;&lt;/a&gt;The analytics controller -- computing real metrics&lt;/h2&gt;
&lt;p&gt;The analytics controller the raw Ahoy data and computes the metrics you'd expect from any analytics dashboard.&lt;/p&gt;
&lt;p&gt;The controller starts by determining the time period and querying both the current and previous periods:&lt;/p&gt;
&lt;pre lang="ruby" style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#b48ead;"&gt;def &lt;/span&gt;&lt;span style="color:#8fa1b3;"&gt;index
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  @&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;period &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;= params[&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;:period&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;] || &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;30d&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  @&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;since &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;= period_start(@&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;period&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  @&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;previous_since &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;= previous_period_start(@&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;period&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  visits = &lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Ahoy&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;::&lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Visit&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.where(&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;started_at &amp;gt;= ?&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;, @&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;since&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  previous_visits = &lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Ahoy&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;::&lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Visit&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.where(
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;started_at &amp;gt;= ? AND started_at &amp;lt; ?&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;, @&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;previous_since&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;, @&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;since
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  )
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  events = &lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Ahoy&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;::&lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Event&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.where(&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;time &amp;gt;= ?&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;, @&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;since&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    .where(&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;name: &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;Page View&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#65737e;"&gt;# ...
&lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four periods are available: today, seven days, 30 days, and 12 months. Each one also queries the equivalent previous period for comparison. The seven-day view compares against the previous seven days, the 30-day view against the previous 30 days.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Unique visitors&lt;/strong&gt; is straightforward -- Ahoy assigns each visitor a &lt;code&gt;visitor_token&lt;/code&gt; based on their IP and user agent:&lt;/p&gt;
&lt;pre lang="ruby" style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#c0c5ce;"&gt;@&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;unique_visitors &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;= visits.distinct.count(&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;:visitor_token&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Bounce rate&lt;/strong&gt; requires grouping events by visit and counting how many visits had only one page view:&lt;/p&gt;
&lt;pre lang="ruby" style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#b48ead;"&gt;def &lt;/span&gt;&lt;span style="color:#8fa1b3;"&gt;compute_bounce_rate&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;(&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;events_scope&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  page_view_counts = events_scope.group(&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;:visit_id&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;).count
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  visits_with_views = page_view_counts.size
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;return &lt;/span&gt;&lt;span style="color:#d08770;"&gt;0 &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;if&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; visits_with_views == &lt;/span&gt;&lt;span style="color:#d08770;"&gt;0
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  bounced = page_view_counts.count { |&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;_&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;, &lt;/span&gt;&lt;span style="color:#bf616a;"&gt;count&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;| count == &lt;/span&gt;&lt;span style="color:#d08770;"&gt;1 &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;}
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  (bounced.&lt;/span&gt;&lt;span style="color:#96b5b4;"&gt;to_f &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;/ visits_with_views * &lt;/span&gt;&lt;span style="color:#d08770;"&gt;100&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;).round(&lt;/span&gt;&lt;span style="color:#d08770;"&gt;0&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;)
&lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A bounce is a visit where the visitor saw exactly one page and left. Simple concept, but getting the query right matters. We group all page view events by &lt;code&gt;visit_id&lt;/code&gt;, count them, and determine what percentage of visits had exactly one event.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Visit duration&lt;/strong&gt; is trickier. You can only calculate duration for visits where the visitor viewed at least two pages -- single-page visits have no second timestamp to measure against. This uses SQLite's &lt;code&gt;strftime&lt;/code&gt; to compute the difference in seconds between the first and last event:&lt;/p&gt;
&lt;pre lang="ruby" style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#b48ead;"&gt;def &lt;/span&gt;&lt;span style="color:#8fa1b3;"&gt;compute_avg_duration&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;(&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;events_scope&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  durations = events_scope
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    .group(&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;:visit_id&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    .having(&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;COUNT(*) &amp;gt;= 2&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    .pluck(&lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Arel&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.sql(
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;      &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;CAST(strftime(&amp;#39;&lt;/span&gt;&lt;span style="color:#d08770;"&gt;%s&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;&amp;#39;, MAX(time)) - strftime(&amp;#39;&lt;/span&gt;&lt;span style="color:#d08770;"&gt;%s&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;&amp;#39;, MIN(time)) AS INTEGER)&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    ))
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;return &lt;/span&gt;&lt;span style="color:#d08770;"&gt;0 &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;if&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; durations.empty?
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  (durations.sum / durations.size.&lt;/span&gt;&lt;span style="color:#96b5b4;"&gt;to_f&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;).round(&lt;/span&gt;&lt;span style="color:#d08770;"&gt;0&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;)
&lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;HAVING(&amp;quot;COUNT(*) &amp;gt;= 2&amp;quot;)&lt;/code&gt; clause is critical. Without it, you'd include single-page visits as zero-duration entries and artificially deflate the average.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Period comparison&lt;/strong&gt; computes the percentage change and flags whether the change is positive or negative -- with special handling for bounce rate, where a decrease is actually good:&lt;/p&gt;
&lt;pre lang="ruby" style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#b48ead;"&gt;def &lt;/span&gt;&lt;span style="color:#8fa1b3;"&gt;percentage_change&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;(&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;current&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;, &lt;/span&gt;&lt;span style="color:#bf616a;"&gt;previous&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;, &lt;/span&gt;&lt;span style="color:#bf616a;"&gt;inverted&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;: &lt;/span&gt;&lt;span style="color:#d08770;"&gt;false&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;return &lt;/span&gt;&lt;span style="color:#d08770;"&gt;nil &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;if&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; previous == &lt;/span&gt;&lt;span style="color:#d08770;"&gt;0 &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;amp;&amp;amp; current == &lt;/span&gt;&lt;span style="color:#d08770;"&gt;0
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;return &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;:new &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;if&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; previous == &lt;/span&gt;&lt;span style="color:#d08770;"&gt;0
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  change = ((current - previous) / previous.&lt;/span&gt;&lt;span style="color:#96b5b4;"&gt;to_f &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;* &lt;/span&gt;&lt;span style="color:#d08770;"&gt;100&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;).round(&lt;/span&gt;&lt;span style="color:#d08770;"&gt;0&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  { &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;value:&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; change, &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;positive:&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; inverted ? change &amp;lt;= &lt;/span&gt;&lt;span style="color:#d08770;"&gt;0 &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;: change &amp;gt;= &lt;/span&gt;&lt;span style="color:#d08770;"&gt;0 &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;}
&lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;inverted: true&lt;/code&gt; flag for bounce rate means that when the bounce rate drops, the badge shows green, not red. Small detail, but it avoids confusing yourself every time you check the dashboard.&lt;/p&gt;
&lt;p&gt;The helper methods that display these metrics in the view are equally direct:&lt;/p&gt;
&lt;pre lang="ruby" style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#65737e;"&gt;# app/helpers/analytics_helper.rb
&lt;/span&gt;&lt;span style="color:#b48ead;"&gt;module &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;AnalyticsHelper
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;def &lt;/span&gt;&lt;span style="color:#8fa1b3;"&gt;format_duration&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;(&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;seconds&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    seconds = seconds.&lt;/span&gt;&lt;span style="color:#96b5b4;"&gt;to_i
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;return &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;0s&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;if&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; seconds &amp;lt;= &lt;/span&gt;&lt;span style="color:#d08770;"&gt;0
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    hours = seconds / &lt;/span&gt;&lt;span style="color:#d08770;"&gt;3600
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    minutes = (seconds % &lt;/span&gt;&lt;span style="color:#d08770;"&gt;3600&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;) / &lt;/span&gt;&lt;span style="color:#d08770;"&gt;60
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    secs = seconds % &lt;/span&gt;&lt;span style="color:#d08770;"&gt;60
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    parts = []
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    parts &amp;lt;&amp;lt; &amp;quot;&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;#{&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;hours&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;}&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;h&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;if&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; hours &amp;gt; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;0
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    parts &amp;lt;&amp;lt; &amp;quot;&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;#{&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;minutes&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;}&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;m&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;if&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; minutes &amp;gt; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;0
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    parts &amp;lt;&amp;lt; &amp;quot;&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;#{&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;secs&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;}&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;s&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;if&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; secs &amp;gt; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;0 &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;|| parts.empty?
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    parts.join(&amp;quot; &amp;quot;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;def &lt;/span&gt;&lt;span style="color:#8fa1b3;"&gt;change_badge&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;(&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;change&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;return &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;&amp;quot; &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;if&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; change.&lt;/span&gt;&lt;span style="color:#96b5b4;"&gt;nil?
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;if&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; change == &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;:new
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;      tag.span(&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;New&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;, &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;class&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;: &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;analytics-kpi-change analytics-kpi-change--new&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;else
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;      css = change[&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;:positive&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;] ? &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;analytics-kpi-change--up&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; : &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;analytics-kpi-change--down&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;      arrow = change[&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;:positive&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;] ? &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;↗&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; : &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;↘&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;      tag.span(&amp;quot;&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;#{&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;arrow&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;} #{&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;change[:value].abs&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;}&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;%&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;,
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;        &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;class&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;: &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;analytics-kpi-change &lt;/span&gt;&lt;span style="color:#ab7967;"&gt;#{&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;css&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;}&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;format_duration&lt;/code&gt; converts seconds into a human-readable string -- &amp;quot;2m 30s&amp;quot; instead of &amp;quot;150.&amp;quot; &lt;code&gt;change_badge&lt;/code&gt; renders the comparison indicator with an arrow and color.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#visualizations-with-pure-svg" aria-hidden="true" class="anchor" id="visualizations-with-pure-svg"&gt;&lt;/a&gt;Visualizations with pure SVG&lt;/h2&gt;
&lt;p&gt;I made a deliberate choice here: no Chart.js, no D3, no JavaScript charting library. The traffic trend chart is rendered entirely in ERB and SVG. One less dependency, works without JavaScript, and the code is surprisingly simple.&lt;/p&gt;
&lt;p&gt;The view calculates SVG coordinates from the data and draws a polyline with a gradient fill:&lt;/p&gt;
&lt;pre lang="erb" style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#ab7967;"&gt;&amp;lt;%
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  points = @&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;trend_data&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.&lt;/span&gt;&lt;span style="color:#96b5b4;"&gt;to_a
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  max_val = points.map(&amp;amp;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;:last&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;).max.&lt;/span&gt;&lt;span style="color:#96b5b4;"&gt;to_f
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  max_val = &lt;/span&gt;&lt;span style="color:#d08770;"&gt;1.0 &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;if&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; max_val == &lt;/span&gt;&lt;span style="color:#d08770;"&gt;0
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  chart_w = &lt;/span&gt;&lt;span style="color:#d08770;"&gt;800
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  chart_h = &lt;/span&gt;&lt;span style="color:#d08770;"&gt;200
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  usable_w = chart_w
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  usable_h = chart_h - &lt;/span&gt;&lt;span style="color:#d08770;"&gt;40  &lt;/span&gt;&lt;span style="color:#65737e;"&gt;# padding
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  coords = points.each_with_index.map &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;do &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;|(&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;_&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;, &lt;/span&gt;&lt;span style="color:#bf616a;"&gt;val&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;), &lt;/span&gt;&lt;span style="color:#bf616a;"&gt;i&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;|
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    x = i * (usable_w.&lt;/span&gt;&lt;span style="color:#96b5b4;"&gt;to_f &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;/ (points.size - &lt;/span&gt;&lt;span style="color:#d08770;"&gt;1&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;))
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    y = &lt;/span&gt;&lt;span style="color:#d08770;"&gt;20 &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;+ usable_h - (val / max_val * usable_h)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    [x.round(&lt;/span&gt;&lt;span style="color:#d08770;"&gt;1&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;), y.round(&lt;/span&gt;&lt;span style="color:#d08770;"&gt;1&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;)]
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  polyline = coords.map { |&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;x&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;, &lt;/span&gt;&lt;span style="color:#bf616a;"&gt;y&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;| &amp;quot;&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;#{&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;x&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;}&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;,&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;#{&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;y&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;}&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; }.join(&amp;quot; &amp;quot;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  area = &amp;quot;&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;#{&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;coords.first[&lt;/span&gt;&lt;span style="color:#d08770;"&gt;0&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;]&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;}&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;,&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;#{&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;chart_h&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;} #{&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;polyline&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;} #{&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;coords.last[&lt;/span&gt;&lt;span style="color:#d08770;"&gt;0&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;]&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;}&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;,&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;#{&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;chart_h&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;}&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;
&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;%&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;svg &lt;/span&gt;&lt;span style="color:#d08770;"&gt;viewBox&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;0 0 800 200&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;preserveAspectRatio&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;none&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;polygon &lt;/span&gt;&lt;span style="color:#d08770;"&gt;points&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;&amp;lt;%=&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt; area &lt;/span&gt;&lt;span style="color:#ab7967;"&gt;%&amp;gt;&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;fill&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;url(#areaGradient)&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; /&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;polyline &lt;/span&gt;&lt;span style="color:#d08770;"&gt;points&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;&amp;lt;%=&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt; polyline &lt;/span&gt;&lt;span style="color:#ab7967;"&gt;%&amp;gt;&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;fill&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;none&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &lt;/span&gt;&lt;span style="color:#d08770;"&gt;stroke&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;var(--accent)&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;stroke-width&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;2.5&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &lt;/span&gt;&lt;span style="color:#d08770;"&gt;stroke-linejoin&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;round&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;stroke-linecap&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;round&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; /&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#ab7967;"&gt;&amp;lt;%&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; coords.each &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;do &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;|&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;x&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;, &lt;/span&gt;&lt;span style="color:#bf616a;"&gt;y&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;| &lt;/span&gt;&lt;span style="color:#ab7967;"&gt;%&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &amp;lt;&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;circle &lt;/span&gt;&lt;span style="color:#d08770;"&gt;cx&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;&amp;lt;%=&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt; x &lt;/span&gt;&lt;span style="color:#ab7967;"&gt;%&amp;gt;&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;cy&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;&amp;lt;%=&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt; y &lt;/span&gt;&lt;span style="color:#ab7967;"&gt;%&amp;gt;&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;r&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;3&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;      &lt;/span&gt;&lt;span style="color:#d08770;"&gt;fill&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;var(--accent)&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;stroke&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;var(--surface)&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;stroke-width&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;1.5&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; /&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#ab7967;"&gt;&amp;lt;% &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end &lt;/span&gt;&lt;span style="color:#ab7967;"&gt;%&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;defs&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &amp;lt;&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;linearGradient &lt;/span&gt;&lt;span style="color:#8fa1b3;"&gt;id&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;areaGradient&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;x1&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;0&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;y1&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;0&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;x2&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;0&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;y2&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;1&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;      &amp;lt;&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;stop &lt;/span&gt;&lt;span style="color:#d08770;"&gt;offset&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;0%&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;stop-color&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;var(--accent)&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;stop-opacity&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;0.2&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; /&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;      &amp;lt;&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;stop &lt;/span&gt;&lt;span style="color:#d08770;"&gt;offset&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;100%&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;stop-color&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;var(--accent)&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#d08770;"&gt;stop-opacity&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;0.02&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; /&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &amp;lt;/&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;linearGradient&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &amp;lt;/&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;defs&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;svg&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The math is basic: map each data point to an x,y coordinate within the SVG viewBox, join them into a polyline string, and create a polygon for the gradient fill area. Grid lines are dashed &lt;code&gt;&amp;lt;line&amp;gt;&lt;/code&gt; elements. Data point dots are &lt;code&gt;&amp;lt;circle&amp;gt;&lt;/code&gt; elements. The whole chart responds to CSS custom properties (&lt;code&gt;var(--accent)&lt;/code&gt;, &lt;code&gt;var(--surface)&lt;/code&gt;), so it works with light and dark themes automatically.&lt;/p&gt;
&lt;p&gt;For tabular data -- top pages, referrers, countries -- I use inline bar visualizations. Each row has a background bar whose width is proportional to the maximum value:&lt;/p&gt;
&lt;pre lang="erb" style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#ab7967;"&gt;&amp;lt;% &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;@&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;top_pages&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.each &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;do &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;|&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;page&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;, &lt;/span&gt;&lt;span style="color:#bf616a;"&gt;count&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;| &lt;/span&gt;&lt;span style="color:#ab7967;"&gt;%&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;tr&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &amp;lt;&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;td&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;      &amp;lt;&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;div &lt;/span&gt;&lt;span style="color:#d08770;"&gt;class&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;analytics-table-bar-cell&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;        &amp;lt;&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;div &lt;/span&gt;&lt;span style="color:#d08770;"&gt;class&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;analytics-table-bar&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;          &lt;/span&gt;&lt;span style="color:#d08770;"&gt;style&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;width: &lt;/span&gt;&lt;span style="color:#ab7967;"&gt;&amp;lt;%= &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;(count / max_page * &lt;/span&gt;&lt;span style="color:#d08770;"&gt;100&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;).round(&lt;/span&gt;&lt;span style="color:#d08770;"&gt;1&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;) &lt;/span&gt;&lt;span style="color:#ab7967;"&gt;%&amp;gt;&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;%&amp;quot;&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;        &amp;lt;/&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;div&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;        &amp;lt;&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;span&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;gt;&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;&amp;lt;%=&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; truncate(page, &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;length: &lt;/span&gt;&lt;span style="color:#d08770;"&gt;60&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;) &lt;/span&gt;&lt;span style="color:#ab7967;"&gt;%&amp;gt;&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;span&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;      &amp;lt;/&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;div&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &amp;lt;/&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;td&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &amp;lt;&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;td &lt;/span&gt;&lt;span style="color:#d08770;"&gt;class&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;=&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;analytics-table-num&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;&amp;gt;&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;&amp;lt;%=&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; number_with_delimiter(count) &lt;/span&gt;&lt;span style="color:#ab7967;"&gt;%&amp;gt;&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;td&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &amp;lt;/&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;tr&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;gt;
&lt;/span&gt;&lt;span style="color:#ab7967;"&gt;&amp;lt;% &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end &lt;/span&gt;&lt;span style="color:#ab7967;"&gt;%&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The bar is just a &lt;code&gt;div&lt;/code&gt; with a percentage width and a translucent background color. The text sits on top. It gives you the visual impact of a bar chart inside a table, with no JavaScript.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#adding-geolocation" aria-hidden="true" class="anchor" id="adding-geolocation"&gt;&lt;/a&gt;Adding geolocation&lt;/h2&gt;
&lt;p&gt;Knowing which countries your visitors come from is valuable, and it doesn't require an external API. MaxMind publishes a free GeoLite2 database that maps IP addresses to countries and cities. Download the &lt;code&gt;.mmdb&lt;/code&gt; file, drop it in your project, and configure Geocoder to use it:&lt;/p&gt;
&lt;pre lang="ruby" style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#65737e;"&gt;# config/initializers/ahoy.rb (geolocation section)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;mmdb_path = &lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Rails&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.root.join(&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;db/maxmind/GeoLite2-City.mmdb&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;)
&lt;/span&gt;&lt;span style="color:#b48ead;"&gt;if&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; mmdb_path.exist?
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Geocoder&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.configure(
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;ip_lookup: :geoip2&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;,
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;geoip2: &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;{ &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;file:&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; mmdb_path }
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  )
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Ahoy&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.geocode = &lt;/span&gt;&lt;span style="color:#d08770;"&gt;true
&lt;/span&gt;&lt;span style="color:#b48ead;"&gt;else
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Ahoy&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.geocode = &lt;/span&gt;&lt;span style="color:#d08770;"&gt;false
&lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The conditional check means the app works without the MaxMind file -- it just skips geolocation. This matters for development environments and CI where you might not have the database file.&lt;/p&gt;
&lt;p&gt;When Ahoy records a visit, Geocoder looks up the IP address against the local MaxMind database and populates the &lt;code&gt;country&lt;/code&gt; field. The controller query is one line:&lt;/p&gt;
&lt;pre lang="ruby" style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#c0c5ce;"&gt;@&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;top_countries &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;= visits.where.not(&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;country: &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;[&lt;/span&gt;&lt;span style="color:#d08770;"&gt;nil&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;, &amp;quot;&amp;quot;])
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  .group(&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;:country&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;).order(&lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Arel&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.sql(&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;count(*) DESC&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;)).limit(&lt;/span&gt;&lt;span style="color:#d08770;"&gt;20&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;).count
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;a href="#testing" aria-hidden="true" class="anchor" id="testing"&gt;&lt;/a&gt;Testing&lt;/h2&gt;
&lt;p&gt;The analytics dashboard has 11 integration tests covering the full range of functionality. The test file uses two helper methods that create Ahoy records directly:&lt;/p&gt;
&lt;pre lang="ruby" style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#b48ead;"&gt;def &lt;/span&gt;&lt;span style="color:#8fa1b3;"&gt;create_visit&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;(&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;visitor_token&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;: &lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;SecureRandom&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.hex,
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;                 &lt;/span&gt;&lt;span style="color:#bf616a;"&gt;started_at&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;: &lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Time&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.current, **&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;attrs&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Ahoy&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;::&lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Visit&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.create!(
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;visit_token: &lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;SecureRandom&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.hex,
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;visitor_token:&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; visitor_token,
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;started_at:&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; started_at,
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    **attrs
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  )
&lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#b48ead;"&gt;def &lt;/span&gt;&lt;span style="color:#8fa1b3;"&gt;create_event&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;(&lt;/span&gt;&lt;span style="color:#bf616a;"&gt;visit&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;:, &lt;/span&gt;&lt;span style="color:#bf616a;"&gt;time&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;: &lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Time&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.current, &lt;/span&gt;&lt;span style="color:#bf616a;"&gt;url&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;: &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;/blog&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Ahoy&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;::&lt;/span&gt;&lt;span style="color:#ebcb8b;"&gt;Event&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.create!(
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;visit:&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; visit,
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;name: &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;Page View&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;,
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;properties: &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;{ &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;url&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; =&amp;gt; url },
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;time:&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; time
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  )
&lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These helpers let you set up precise test scenarios. For example, testing bounce rate:&lt;/p&gt;
&lt;pre lang="ruby" style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#96b5b4;"&gt;test &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;calculates bounce rate from single-page visits&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot; &lt;/span&gt;&lt;span style="color:#b48ead;"&gt;do
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  v1 = create_visit(&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;started_at: &lt;/span&gt;&lt;span style="color:#d08770;"&gt;1&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.day.ago)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  create_event(&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;visit:&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; v1, &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;time: &lt;/span&gt;&lt;span style="color:#d08770;"&gt;1&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.day.ago) 
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  v2 = create_visit(&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;started_at: &lt;/span&gt;&lt;span style="color:#d08770;"&gt;1&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.day.ago)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  create_event(&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;visit:&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; v2, &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;time: &lt;/span&gt;&lt;span style="color:#d08770;"&gt;1&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.day.ago)
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  create_event(&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;visit:&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt; v2, &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;time: &lt;/span&gt;&lt;span style="color:#d08770;"&gt;1&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.day.ago + &lt;/span&gt;&lt;span style="color:#d08770;"&gt;5&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;.minutes, &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;url: &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;/about&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;) 
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  get analytics_url
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  assert_response &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;:success
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  assert_select &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;.analytics-kpi-card .analytics-kpi-value&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;, &lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;text: &lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;50%&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;
&lt;/span&gt;&lt;span style="color:#b48ead;"&gt;end
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One visit with one page view, one visit with two page views. Expected bounce rate: 50%. The test asserts the rendered HTML contains exactly that value. Other tests cover unique visitor deduplication, visit duration calculation, zero-data handling, period selection, country display, and comparison badges.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-claude-code-workflow-that-made-this-fast" aria-hidden="true" class="anchor" id="the-claude-code-workflow-that-made-this-fast"&gt;&lt;/a&gt;The Claude Code workflow that made this fast&lt;/h2&gt;
&lt;p&gt;This is where I want to be specific about what &amp;quot;AI pair programming&amp;quot; actually means in practice, because most descriptions stay vague.&lt;/p&gt;
&lt;p&gt;Claude Code is a CLI tool. You run it in your terminal, it has access to your project files, and you have a conversation about what to build. It reads your code, suggests changes, writes files, runs tests. Think of it as a very capable colleague who can type fast but needs you to steer.&lt;/p&gt;
&lt;p&gt;Ariadna is a plugin for Claude Code that adds structure. Without Ariadna, a typical Claude Code session goes: &amp;quot;Build me analytics.&amp;quot; Claude Code starts writing code, you review as it goes, you course-correct when needed. This works fine for small tasks. For anything bigger, you end up with a wandering conversation and code that reflects that wandering.&lt;/p&gt;
&lt;p&gt;With Ariadna, the workflow has explicit phases:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Design spec.&lt;/strong&gt; You describe what you want to build, why you're building it, and the key architectural decisions. For the analytics dashboard, the spec covered: which gem to use for tracking (Ahoy), how to handle geolocation (local MaxMind file), what metrics to compute (unique visitors, bounce rate, duration, period comparison), how to render charts (pure SVG, no JavaScript libraries), and access control (public, no authentication). The spec is a document, not a conversation -- it's written to a file that becomes the source of truth for the build.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Implementation plan.&lt;/strong&gt; Ariadna generates an ordered list of tasks from the spec. Each task specifies exactly which files to create or modify, what the code should accomplish, and a pre-written commit message. The plan for the analytics dashboard had tasks like: &amp;quot;Install and configure Ahoy gem with cookie-less tracking,&amp;quot; &amp;quot;Create Trackable concern with smart request filtering,&amp;quot; &amp;quot;Build AnalyticsController with KPI computation methods,&amp;quot; &amp;quot;Add SVG chart generation to analytics view,&amp;quot; &amp;quot;Configure MaxMind geolocation,&amp;quot; and &amp;quot;Write integration tests.&amp;quot; Each task included the specific file paths, the approach, and success criteria.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Execution.&lt;/strong&gt; Claude Code follows the plan task by task. It writes the code, runs the tests, and creates a commit. You review each commit before the next task starts. If something needs adjustment -- &amp;quot;that bounce rate calculation is wrong&amp;quot; or &amp;quot;I want the chart to use CSS custom properties&amp;quot; -- you say so, Claude Code fixes it, and the commit is amended before moving on.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#making-it-public" aria-hidden="true" class="anchor" id="making-it-public"&gt;&lt;/a&gt;Making it public&lt;/h2&gt;
&lt;p&gt;Most analytics dashboards sit behind a login. I made mine public because transparent analytics builds trust. When someone reads a blog post and wonders &amp;quot;does anyone actually read this?&amp;quot; they can check for themselves.&lt;/p&gt;
&lt;p&gt;There's no sensitive data on the page -- just aggregate metrics. Individual visitor IPs and tokens never appear in the view. The worst someone can learn from my public analytics is which pages are popular and what countries my readers are from.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#what-i-learned" aria-hidden="true" class="anchor" id="what-i-learned"&gt;&lt;/a&gt;What I learned&lt;/h2&gt;
&lt;p&gt;Building your own analytics dashboard takes less time than you'd expect. The &lt;a href="https://github.com/ankane/ahoy"&gt;Ahoy gem&lt;/a&gt; handles the hard parts -- visit tracking, visitor identification, event storage. What's left is a controller that runs queries and a view that renders the results.&lt;/p&gt;
&lt;p&gt;The total footprint: an entire analytics system in under 600 lines of code (including views). Every line is readable, every query is understandable, and every metric is computed exactly the way I want it.&lt;/p&gt;
&lt;p&gt;Using Claude Code with the Ariadna planning workflow meant the code came out clean on the first pass. The spec forced me to make architectural decisions up front -- cookie-less tracking, pure SVG charts, inverted bounce rate comparison -- instead of discovering them mid-build. The plan gave Claude Code a clear path to follow, which meant fewer course corrections and more time reviewing code instead of writing prompts.&lt;/p&gt;
&lt;p&gt;The stack is simple: Rails + Ahoy + Geocoder + MaxMind + CSS custom properties + ERB. No JavaScript analytics library, no charting framework, no external tracking service. Everything runs on the same server that runs the blog.&lt;/p&gt;
&lt;p&gt;Check out my analytics at &lt;a href="https://alvareznavarro.es/analytics"&gt;alvareznavarro.es/analytics&lt;/a&gt;. Then build your own. If you're running Rails, you already have everything you need.&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="AI Engineering"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/194</id>
    <published>2026-03-18T00:00:00Z</published>
    <updated>2026-03-17T22:12:06Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2026/03/the-five-ways-to-customize-claude-code"/>
    <title>The Five Ways to Customize Claude Code</title>
    <content type="html">&lt;p&gt;Claude Code ships with five customization mechanisms — &lt;code&gt;CLAUDE.md&lt;/code&gt;, Skills, Sub-agents, Hooks, and MCP servers — and each one solves a different problem. Choosing the wrong one means either cluttering every conversation with irrelevant context or missing critical instructions when they matter most.&lt;/p&gt;
&lt;p&gt;After watching Anthropic's breakdown of these tools, I wanted to distill the key ideas and share how I think about each one in practice.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#claudemd--always-on-project-standards" aria-hidden="true" class="anchor" id="claudemd--always-on-project-standards"&gt;&lt;/a&gt;CLAUDE.md — Always-On Project Standards&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; loads into &lt;strong&gt;every single conversation&lt;/strong&gt;. It's the equivalent of a project's &lt;code&gt;.editorconfig&lt;/code&gt; or &lt;code&gt;.rubocop.yml&lt;/code&gt; — rules that apply unconditionally.&lt;/p&gt;
&lt;p&gt;Use it for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Framework preferences&lt;/strong&gt; — &amp;quot;Use TypeScript strict mode&amp;quot; or &amp;quot;Follow Rails conventions&amp;quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hard constraints&lt;/strong&gt; — &amp;quot;Never modify the database schema directly&amp;quot; or &amp;quot;Always run tests before committing&amp;quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Coding style&lt;/strong&gt; — Naming conventions, file organization patterns, preferred libraries&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The key principle: if the instruction is relevant regardless of what task you're working on, it belongs in &lt;code&gt;CLAUDE.md&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What doesn't belong here&lt;/strong&gt;: detailed checklists, task-specific procedures, or domain knowledge that only applies sometimes. That's what Skills are for.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#skills--on-demand-expertise" aria-hidden="true" class="anchor" id="skills--on-demand-expertise"&gt;&lt;/a&gt;Skills — On-Demand Expertise&lt;/h2&gt;
&lt;p&gt;Skills are the opposite of &lt;code&gt;CLAUDE.md&lt;/code&gt; in one important way: they load &lt;strong&gt;on demand&lt;/strong&gt;, only when Claude matches your request to a relevant skill.&lt;/p&gt;
&lt;p&gt;Think of it this way: your PR review checklist doesn't need to be in context when you're writing new code. It activates when you ask for a review. A deployment procedure loads when you mention deploying.&lt;/p&gt;
&lt;p&gt;Use Skills for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Task-specific expertise&lt;/strong&gt; — Review checklists, deployment guides, content templates&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Knowledge that's only relevant sometimes&lt;/strong&gt; — Regulatory references, API documentation, style guides for specific content types&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Detailed procedures&lt;/strong&gt; that would add noise if they were always present&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The distinction is simple: &lt;code&gt;CLAUDE.md&lt;/code&gt; is &amp;quot;always apply this&amp;quot;, Skills are &amp;quot;apply this when the topic comes up.&amp;quot;&lt;/p&gt;
&lt;h2&gt;&lt;a href="#sub-agents--isolated-execution-contexts" aria-hidden="true" class="anchor" id="sub-agents--isolated-execution-contexts"&gt;&lt;/a&gt;Sub-Agents — Isolated Execution Contexts&lt;/h2&gt;
&lt;p&gt;Skills add knowledge to your &lt;em&gt;current&lt;/em&gt; conversation — they enhance Claude's reasoning within the same context. Sub-agents are fundamentally different: they &lt;strong&gt;run in a separate context entirely&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;A sub-agent receives a task, works on it independently with its own tool access, and returns results back to your main conversation. The main conversation and the sub-agent are isolated from each other.&lt;/p&gt;
&lt;p&gt;Use Sub-agents when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You want to &lt;strong&gt;delegate a task&lt;/strong&gt; to a separate execution context&lt;/li&gt;
&lt;li&gt;The delegated work needs &lt;strong&gt;different tool access&lt;/strong&gt; than your main conversation&lt;/li&gt;
&lt;li&gt;You want &lt;strong&gt;isolation&lt;/strong&gt; between the delegated work and what you're doing — so one doesn't pollute the other&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The mental model: Skills are like calling a colleague over to look at your screen. Sub-agents are like sending a task to a colleague who works on it at their own desk and brings you back the results.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#hooks--event-driven-automation" aria-hidden="true" class="anchor" id="hooks--event-driven-automation"&gt;&lt;/a&gt;Hooks — Event-Driven Automation&lt;/h2&gt;
&lt;p&gt;Hooks fire on events, not on requests. This is the critical distinction from Skills.&lt;/p&gt;
&lt;p&gt;A Hook might run a linter every time Claude saves a file, validate input before certain tool calls, or trigger automated side effects after Claude performs an action. They're reactive — they respond to &lt;em&gt;what Claude does&lt;/em&gt;, not &lt;em&gt;what you ask&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Use Hooks for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Operations that should run on every file save&lt;/strong&gt; — linting, formatting, validation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Validation before specific tool calls&lt;/strong&gt; — sanity checks, safety guards&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automated side effects&lt;/strong&gt; — logging, notifications, synchronization&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Meanwhile, Skills are &lt;em&gt;request-driven&lt;/em&gt;: they activate based on what you're asking Claude to do. Hooks are &lt;em&gt;event-driven&lt;/em&gt;: they activate based on what Claude is doing.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#mcp-servers--external-tool-integration" aria-hidden="true" class="anchor" id="mcp-servers--external-tool-integration"&gt;&lt;/a&gt;MCP Servers — External Tool Integration&lt;/h2&gt;
&lt;p&gt;MCP (Model Context Protocol) servers provide external tools and data sources that Claude can use. While the other four mechanisms customize Claude's behavior and knowledge, MCP servers extend what Claude can actually &lt;em&gt;do&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;They connect Claude to external systems — databases, APIs, file systems, third-party services — giving it capabilities beyond its built-in tools.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#putting-it-all-together" aria-hidden="true" class="anchor" id="putting-it-all-together"&gt;&lt;/a&gt;Putting It All Together&lt;/h2&gt;
&lt;p&gt;A well-configured setup typically combines multiple mechanisms, each handling its own specialty:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;When It Applies&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every conversation&lt;/td&gt;
&lt;td&gt;Project-wide standards and constraints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skills&lt;/td&gt;
&lt;td&gt;When topic matches&lt;/td&gt;
&lt;td&gt;Task-specific expertise and procedures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sub-agents&lt;/td&gt;
&lt;td&gt;When delegating&lt;/td&gt;
&lt;td&gt;Isolated task execution with separate context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hooks&lt;/td&gt;
&lt;td&gt;On specific events&lt;/td&gt;
&lt;td&gt;Automated operations triggered by actions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP Servers&lt;/td&gt;
&lt;td&gt;When external tools needed&lt;/td&gt;
&lt;td&gt;Connections to external systems and data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The key insight is: &lt;strong&gt;don't force everything into one mechanism&lt;/strong&gt;. If you're putting detailed review checklists in &lt;code&gt;CLAUDE.md&lt;/code&gt;, you're adding noise to every conversation. If you're trying to use Skills for automated validation on file saves, you're using the wrong tool.&lt;/p&gt;
&lt;p&gt;Each mechanism has a clear purpose. Match the mechanism to the need, and combine them for comprehensive customization.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#my-setup-as-an-example" aria-hidden="true" class="anchor" id="my-setup-as-an-example"&gt;&lt;/a&gt;My Setup as an Example&lt;/h2&gt;
&lt;p&gt;For context, on the Rails projects I work on, my approach looks roughly like this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CLAUDE.md&lt;/strong&gt;: Rails conventions, test commands, deployment constraints, coding style preferences&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Skills&lt;/strong&gt;: Content creation templates, PR review checklists, compliance reference material&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hooks&lt;/strong&gt;: Running RuboCop on saved files, validating migrations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MCP Servers&lt;/strong&gt;: Connecting to project management tools, calendar, email&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The result is a Claude that knows my project's rules at all times, brings in specialized knowledge when relevant, automates repetitive checks, and can interact with external services — all without any single conversation being overloaded with irrelevant context.&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="AI Engineering"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/193</id>
    <published>2026-03-17T00:00:00Z</published>
    <updated>2026-03-17T07:40:36Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2026/03/saas-is-not-dying-saas-is-evolving"/>
    <title>SaaS is not dying, SaaS is evolving</title>
    <content type="html">&lt;p&gt;There's a narrative gaining traction in tech circles: AI will kill SaaS. The argument goes something like this — if anyone can prompt an AI to build a custom app in a weekend, why would they pay monthly for someone else's software?&lt;/p&gt;
&lt;p&gt;It sounds compelling. It's also wrong.&lt;/p&gt;
&lt;p&gt;SaaS isn't dying. It's being forced to evolve.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#everyone-can-cook-not-everyone-runs-a-restaurant" aria-hidden="true" class="anchor" id="everyone-can-cook-not-everyone-runs-a-restaurant"&gt;&lt;/a&gt;Everyone can cook, not everyone runs a restaurant&lt;/h2&gt;
&lt;p&gt;The &amp;quot;AI replaces SaaS&amp;quot; argument is essentially saying that because you have a kitchen, you'll never go to a restaurant again. Sure, you &lt;em&gt;can&lt;/em&gt; cook. You might even enjoy it. But there's a reason restaurants exist: consistency, expertise, the fact that someone else handles sourcing ingredients, managing health inspections, and cleaning up afterwards.&lt;/p&gt;
&lt;p&gt;The same applies to software. Yes, AI can help you vibe-code an expense tracker over a weekend. It might even work well — for you, on your laptop, with your data. But the moment you need multi-user access, audit trails, regulatory compliance, data backups, or integration with your bank... you're no longer cooking dinner. You're running a restaurant.&lt;/p&gt;
&lt;p&gt;The invisible cost of software isn't building version one. It's maintaining version 347 while keeping everything else running.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#where-ai-actually-changes-the-game-for-saas" aria-hidden="true" class="anchor" id="where-ai-actually-changes-the-game-for-saas"&gt;&lt;/a&gt;Where AI actually changes the game for SaaS&lt;/h2&gt;
&lt;p&gt;Instead of replacing SaaS, AI should be transforming what SaaS products can do. Here are the areas where I see the most immediate impact.&lt;/p&gt;
&lt;h3&gt;&lt;a href="#populating-forms-should-feel-effortless" aria-hidden="true" class="anchor" id="populating-forms-should-feel-effortless"&gt;&lt;/a&gt;Populating forms should feel effortless&lt;/h3&gt;
&lt;p&gt;Nobody likes filling out forms. Not employees, not HR managers, not accountants. AI should be the layer that eliminates the friction: scan a document, extract the data, pre-fill the fields, ask for confirmation. The user's job shifts from data entry to data validation.&lt;/p&gt;
&lt;p&gt;This doesn't replace the SaaS product underneath. It makes it dramatically better. The form, the validation rules, the storage, the permissions — all of that still needs to exist. AI just removes the most tedious part of interacting with it.&lt;/p&gt;
&lt;h3&gt;&lt;a href="#business-intelligence-should-be-proactive-not-passive" aria-hidden="true" class="anchor" id="business-intelligence-should-be-proactive-not-passive"&gt;&lt;/a&gt;Business intelligence should be proactive, not passive&lt;/h3&gt;
&lt;p&gt;Traditional BI in SaaS works like this: you build a dashboard, you check the dashboard, you notice something, you act on it. The problem is that most people don't check their dashboards often enough, and when they do, they're looking at last month's data.&lt;/p&gt;
&lt;p&gt;AI flips this model. Instead of waiting for you to ask the right question, the system should surface anomalies, generate custom reports on demand, and alert you about trends before they become problems. &amp;quot;Your absenteeism rate spiked 40% this quarter compared to your industry average&amp;quot; is infinitely more useful than a chart you might glance at once a month.&lt;/p&gt;
&lt;p&gt;This is where SaaS has a massive advantage over any DIY tool: aggregated intelligence across thousands of companies. Your weekend project only knows about your data. A mature SaaS product can benchmark you against anonymized data from your entire industry.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-local-ai-experiment" aria-hidden="true" class="anchor" id="the-local-ai-experiment"&gt;&lt;/a&gt;The local AI experiment&lt;/h2&gt;
&lt;p&gt;Here's something I've been exploring lately: running AI models locally. Not as a replacement for frontier models, but as a practical layer for specific, repetitive tasks.&lt;/p&gt;
&lt;p&gt;Frontier models like Claude or GPT-5 are extraordinary, but they're expensive for high-volume, low-complexity operations. If your SaaS needs to classify 10,000 support tickets a day or extract data from thousands of invoices, sending each one to a frontier API burns through your budget fast.&lt;/p&gt;
&lt;p&gt;Training — or more precisely, fine-tuning — a smaller model for a specific task is, for now, the most practical solution. You trade general intelligence for speed and cost efficiency on a narrow problem. A 7B parameter model fine-tuned on your domain-specific data can outperform a general-purpose frontier model on that one task, at a fraction of the cost.&lt;/p&gt;
&lt;p&gt;This creates a new architecture pattern for SaaS: use local or fine-tuned models for the heavy lifting (classification, extraction, routine analysis), and reserve frontier models for the complex, nuanced tasks that justify the cost (generating insights, handling edge cases, natural language interaction).&lt;/p&gt;
&lt;p&gt;The SaaS products that figure out this balance will have a significant cost advantage — and that advantage gets passed to customers.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#saas-pricing-will-evolve-too" aria-hidden="true" class="anchor" id="saas-pricing-will-evolve-too"&gt;&lt;/a&gt;SaaS pricing will evolve too&lt;/h2&gt;
&lt;p&gt;The traditional per-seat model made sense when software was a tool that humans operated. More humans, more seats, more revenue. But AI changes that equation. If an AI agent handles 60% of the work that three employees used to do, paying for three seats feels wrong.&lt;/p&gt;
&lt;p&gt;SaaS pricing is already shifting. Expect to see more of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Band-based flat tiers&lt;/strong&gt; — pay based on company size or usage volume, not headcount&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Outcome-based pricing&lt;/strong&gt; — pay for results delivered (invoices processed, reports generated, candidates screened) rather than access granted&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hybrid models&lt;/strong&gt; — a base platform fee plus AI-powered features priced by consumption&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This isn't the death of SaaS revenue. It's a realignment. The products that deliver measurable value will charge for that value. The ones that were just charging for access to a UI will struggle — and honestly, they should.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-real-question-isnt-saas-or-ai" aria-hidden="true" class="anchor" id="the-real-question-isnt-saas-or-ai"&gt;&lt;/a&gt;The real question isn't &amp;quot;SaaS or AI&amp;quot;&lt;/h2&gt;
&lt;p&gt;The narrative of AI killing SaaS creates a false dichotomy. The real evolution is AI &lt;em&gt;inside&lt;/em&gt; SaaS — making products smarter, interactions smoother, and pricing more aligned with value.&lt;/p&gt;
&lt;p&gt;The vibe-coded weekend apps will keep appearing. Some will even be useful. But the vast majority of businesses will continue choosing maintained, integrated, compliant software built by teams who do nothing but solve that specific problem, every single day.&lt;/p&gt;
&lt;p&gt;SaaS isn't dying. The lazy version of SaaS is dying.&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="AI Engineering"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/192</id>
    <published>2026-03-05T00:00:00Z</published>
    <updated>2026-03-05T18:54:17Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2026/03/introducing-mario-v2-persistent-brand-context-for-claude-code-content-creation"/>
    <title>Introducing Mario v2 — persistent brand context for Claude Code content creation</title>
    <content type="html">&lt;p&gt;If you've ever used Claude Code for marketing content, you've probably hit&lt;br /&gt;
the same wall I did: every new session starts from zero. No memory of your&lt;br /&gt;
brand voice, no idea who your audience is, no clue about your competitors.&lt;br /&gt;
You end up copy-pasting the same context over and over, and the output&lt;br /&gt;
still feels… generic.&lt;/p&gt;
&lt;p&gt;That's the problem Mario was built to solve, and v2 takes it a lot further.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#what-mario-does" aria-hidden="true" class="anchor" id="what-mario-does"&gt;&lt;/a&gt;What Mario does&lt;/h2&gt;
&lt;p&gt;Mario is a Ruby gem that turns Claude Code into a brand-aware content&lt;br /&gt;
engine. You run it once to build your brand foundations — identity, voice,&lt;br /&gt;
audience personas, competitive landscape, messaging framework — and those&lt;br /&gt;
foundations persist across every session after that.&lt;/p&gt;
&lt;p&gt;The workflow is straightforward: you run /mario:new-project to set up your&lt;br /&gt;
brand, then /mario:create whenever you need content. Mario loads your brand&lt;br /&gt;
context automatically, runs topic research in the background, and routes&lt;br /&gt;
the work to specialised marketing agents depending on what you're creating&lt;br /&gt;
— whether that's web copy, email campaigns, social posts, or SEO content.&lt;/p&gt;
&lt;p&gt;No more re-explaining who you are every time you open a new session.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-brand-foundations" aria-hidden="true" class="anchor" id="the-brand-foundations"&gt;&lt;/a&gt;The brand foundations&lt;/h2&gt;
&lt;p&gt;When you initialise a project, Mario generates 8 documents through a&lt;br /&gt;
structured interview process: Brand identity, Voice &amp;amp; tone, Audience&lt;br /&gt;
personas, Competitive landscape, Messaging framework, Product/service&lt;br /&gt;
details, Channels &amp;amp; distribution, and a synthesised Brand bible for quick&lt;br /&gt;
reference.&lt;/p&gt;
&lt;p&gt;These aren't throwaway files. They're the persistent context that every&lt;br /&gt;
piece of content draws from, which is what keeps your output consistent&lt;br /&gt;
whether you're writing a blog post on Monday or an ad campaign on Friday.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#specialised-agents" aria-hidden="true" class="anchor" id="specialised-agents"&gt;&lt;/a&gt;Specialised agents&lt;/h2&gt;
&lt;p&gt;One thing I'm particularly happy with in v2 is the agent architecture.&lt;br /&gt;
Instead of dumping everything into a single prompt, Mario uses&lt;br /&gt;
orchestrators that spawn domain-specific executors — strategy, web copy,&lt;br /&gt;
email, social, SEO, paid ads — each loaded with its own marketing&lt;br /&gt;
frameworks and best practices.&lt;/p&gt;
&lt;p&gt;There are also research agents that run in parallel to analyze competing&lt;br /&gt;
content before you even start writing, and audit agents that can score a&lt;br /&gt;
website across six dimensions. The orchestrators themselves stay&lt;br /&gt;
lightweight, using roughly 10-15% of context, so the specialised agents&lt;br /&gt;
have room to actually do their work.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#audits-and-competitor-analysis" aria-hidden="true" class="anchor" id="audits-and-competitor-analysis"&gt;&lt;/a&gt;Audits and competitor analysis&lt;/h2&gt;
&lt;p&gt;Two features that have been really useful in practice: /mario:audit runs a&lt;br /&gt;
comprehensive scoring analysis of any website with five parallel agents&lt;br /&gt;
evaluating content, conversion, SEO, positioning, and brand consistency. If&lt;br /&gt;
you just need a quick read, /mario:quick-audit gives you a 60-second&lt;br /&gt;
snapshot.&lt;/p&gt;
&lt;p&gt;And /mario:competitors lets you run a side-by-side comparison of competitor&lt;br /&gt;
sites against your own positioning. It's a solid starting point for&lt;br /&gt;
figuring out where you stand and where the gaps are.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#getting-started" aria-hidden="true" class="anchor" id="getting-started"&gt;&lt;/a&gt;Getting started&lt;/h2&gt;
&lt;p&gt;Installation is simple:&lt;/p&gt;
&lt;pre style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#c0c5ce;"&gt;bash
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;gem install marketing_mario
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;mario install --global
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You'll need Ruby 3.1+ and Claude Code access. From there, run&lt;br /&gt;
/mario:new-project to build your foundations and you're ready to go. The&lt;br /&gt;
whole thing is MIT licensed and the source is on GitHub&lt;br /&gt;
&lt;a href="https://github.com/jorgegorka/mario"&gt;https://github.com/jorgegorka/mario&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#whats-next" aria-hidden="true" class="anchor" id="whats-next"&gt;&lt;/a&gt;What's next&lt;/h2&gt;
&lt;p&gt;I'm already working on improvements based on how people are using it in the&lt;br /&gt;
real world. If you give it a try, I'd genuinely appreciate feedback — open&lt;br /&gt;
an issue, send a PR, or just let me know what works and what doesn't.&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="AI Engineering"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/187</id>
    <published>2026-03-03T00:00:00Z</published>
    <updated>2026-03-03T21:33:00Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2026/03/there-s-never-been-a-better-time-to-become-a-web-developer"/>
    <title>There's never been a better time to become a web developer</title>
    <content type="html">&lt;p&gt;I've been building for the web for over two decades. I've survived the jQuery era, the rise and fall of framework fatigue, and more &amp;quot;Ruby on Rails is dead&amp;quot; takes than I can count. And I'm telling you right now: if you're thinking about getting into web development, or you're already in it and wondering where things are headed, this is the moment.&lt;/p&gt;
&lt;p&gt;Not because the tools are shinier. Not because salaries are up. Because the job itself has fundamentally changed — and it changed in our favor.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#it-was-never-about-the-code" aria-hidden="true" class="anchor" id="it-was-never-about-the-code"&gt;&lt;/a&gt;It was never about the code.&lt;/h2&gt;
&lt;p&gt;Here's something that took me years to internalize: web development was never about writing code. It was always about solving problems for people. The code was just the medium.&lt;/p&gt;
&lt;p&gt;Think about it. Nobody hires you because you write elegant Ruby or perfectly typed TypeScript. They hire you because their users need something — a workflow that doesn't suck, a way to get paid faster, a dashboard that actually tells them what's going on. Your job is to figure out what that something is and make it real.&lt;/p&gt;
&lt;p&gt;The problem is that for a long time, the &amp;quot;making it real&amp;quot; part consumed so much energy that we barely had time for the &amp;quot;figuring out&amp;quot; part. You'd spend three days fighting CSS grid or debugging a race condition, and by the time you shipped, nobody remembered what problem you were solving in the first place.&lt;/p&gt;
&lt;p&gt;That ratio has flipped.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#ai-handles-the-typing-you-handle-the-thinking" aria-hidden="true" class="anchor" id="ai-handles-the-typing-you-handle-the-thinking"&gt;&lt;/a&gt;AI handles the typing. You handle the thinking.&lt;/h2&gt;
&lt;p&gt;AI code assistants are writing most of the boilerplate now. And honestly? They're pretty good at it. Not perfect — you still need to review, refactor, and make architectural decisions — but the days of spending an afternoon scaffolding a CRUD interface are over.&lt;/p&gt;
&lt;p&gt;What this means in practice is that you finally have time to do the parts of the job that actually matter. Validating requirements with stakeholders before you write a single line. Questioning whether the proposed solution is even the right one. Thinking about edge cases, accessibility, performance, security — all the stuff that separates a feature from a good feature.&lt;/p&gt;
&lt;p&gt;AI didn't replace developers. It removed the busywork that was preventing developers from doing their actual job.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#iteration-speed-is-a-superpower" aria-hidden="true" class="anchor" id="iteration-speed-is-a-superpower"&gt;&lt;/a&gt;Iteration speed is a superpower.&lt;/h2&gt;
&lt;p&gt;Here's where it gets really interesting. When generating code takes minutes instead of days, you can produce multiple solutions to the same problem. Not hypothetically — literally. You can build two different approaches, put them in front of real users, and let data decide which one wins.&lt;/p&gt;
&lt;p&gt;A/B testing used to be something only companies with dedicated growth teams could pull off. Now? A single developer can spin up variants, instrument them, and iterate based on results. You're not guessing anymore. You're experimenting.&lt;/p&gt;
&lt;p&gt;That changes the entire dynamic of how we build products. Less &amp;quot;I think this is the right approach&amp;quot; and more &amp;quot;let's find out.&amp;quot;.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-end-of-backend-developer-vs-frontend-developer" aria-hidden="true" class="anchor" id="the-end-of-backend-developer-vs-frontend-developer"&gt;&lt;/a&gt;The end of backend developer vs. frontend developer&lt;/h2&gt;
&lt;p&gt;I'm going to say something that might be uncomfortable: the distinction between &amp;quot;backend developer&amp;quot; and &amp;quot;frontend developer&amp;quot; is dying. And if you're clinging to one of those labels, it's time to let go.&lt;/p&gt;
&lt;p&gt;AI has lowered the barrier to working across the entire stack. The frontend specialist who &amp;quot;doesn't do databases&amp;quot; and the backend engineer who &amp;quot;doesn't touch CSS&amp;quot; — those roles made sense when each layer required deep, specialized knowledge just to be productive. But the tools have changed. You can scaffold a Rails application in seconds, write a new feature, test it, and deploy it — all in the same afternoon.&lt;/p&gt;
&lt;p&gt;The market is moving toward full-stack web developers. People who understand the whole picture, who can own a feature end to end, who don't need to wait for three different teams to align before something ships.&lt;/p&gt;
&lt;p&gt;Either you're a web developer, or you're competing against someone who is. Adapt accordingly.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#youre-a-builder-now" aria-hidden="true" class="anchor" id="youre-a-builder-now"&gt;&lt;/a&gt;You're a builder now&lt;/h2&gt;
&lt;p&gt;This is the mindset shift that matters most. You're not a &amp;quot;developer&amp;quot; in the old sense of the word — someone measured by lines of code, pull requests, or how many Jira tickets they close. You're a builder.&lt;/p&gt;
&lt;p&gt;And builders are measured by what they ship.&lt;/p&gt;
&lt;p&gt;How many features did you deliver this quarter? How many improvements made it to production? Did user retention go up? Did support tickets go down? Those are the questions that matter now. Not &amp;quot;how clean is your git history&amp;quot; or &amp;quot;how many code reviews did you do.&amp;quot;&lt;/p&gt;
&lt;p&gt;I'm not saying code quality doesn't matter — it does, as a means to an end. But the end is always the same: working software in production, making money for your company, solving problems for your users.&lt;/p&gt;
&lt;p&gt;The best code in the world is worthless if it's sitting in a branch that never got merged.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-best-code-is-the-code-in-production" aria-hidden="true" class="anchor" id="the-best-code-is-the-code-in-production"&gt;&lt;/a&gt;The best code is the code in production&lt;/h2&gt;
&lt;p&gt;Let me be blunt about this one. The best code is the code that's in production making money for your company. Full stop.&lt;/p&gt;
&lt;p&gt;Not the code that's &amp;quot;architecturally pure.&amp;quot; Not the code that follows every pattern in the book. Not the code that got praised in a conference talk. The code that shipped, that works, that users interact with every day, that generates revenue.&lt;/p&gt;
&lt;p&gt;This isn't an excuse to ship garbage. It's a reminder that perfection is the enemy of delivery. A well-tested, slightly imperfect feature that's live and generating value beats a beautifully engineered feature that's been &amp;quot;almost ready&amp;quot; for three sprints.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#three-okrs-for-the-ai-development-era" aria-hidden="true" class="anchor" id="three-okrs-for-the-ai-development-era"&gt;&lt;/a&gt;Three OKRs for the AI development era&lt;/h2&gt;
&lt;p&gt;If you take nothing else from this post, take this. In this new era, every feature you build should be measured against three criteria. Think of them as your personal OKRs:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Useful.&lt;/strong&gt; Does it solve a real problem? Did someone actually ask for this, or are you building it because it's technically interesting? If users don't need it, it doesn't matter how well it's built.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Performant.&lt;/strong&gt; Does it work fast enough that users don't notice it? Performance isn't a nice-to-have. A slow feature is a broken feature.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Secure.&lt;/strong&gt; Is it safe? Did you think about authorisation, input validation, data exposure? Security isn't something you bolt on after launch. It's baked into every decision from day one.&lt;/p&gt;
&lt;p&gt;Useful. Performant. Secure. If your feature checks all three boxes, you did your job. If it doesn't, nothing else matters.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-opportunity-is-right-now" aria-hidden="true" class="anchor" id="the-opportunity-is-right-now"&gt;&lt;/a&gt;The opportunity is right now&lt;/h2&gt;
&lt;p&gt;We're in one of those rare inflection points where the tools, the market, and the culture are all aligned in favor of people who build things for the web.&lt;/p&gt;
&lt;p&gt;AI is handling the grunt work. The industry is consolidating around full-stack, outcome-oriented roles. Companies are starting to reward shipping over process. And the web itself — still the most open, most accessible, most democratic platform ever built — isn't going anywhere.&lt;/p&gt;
&lt;p&gt;If you're already a web developer, level up. Learn to think across the stack. Embrace AI as a tool, not a threat. Focus on outcomes, not output.&lt;/p&gt;
&lt;p&gt;If you're thinking about becoming one, stop thinking and start building. The barrier to entry has never been lower, and the ceiling has never been higher.&lt;/p&gt;
&lt;p&gt;There's never been a better time.&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="AI Engineering"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/185</id>
    <published>2026-03-01T00:00:00Z</published>
    <updated>2026-03-01T18:46:49Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2026/03/ruby-on-rails-efficient-development-with-claude-code"/>
    <title>Ruby on Rails efficient development with Claude Code</title>
    <content type="html">&lt;p&gt;My son Mario Alvarez Navarro and I released &lt;a href="https://github.com/jorgegorka/ariadna"&gt;Ariadna&lt;/a&gt;, a Ruby gem that turns Claude Code into a disciplined engineering team for Rails projects.&lt;/p&gt;
&lt;p&gt;Claude Code is exceptional for quick changes and one-off tasks, but when work gets complex (new modules, intricate integrations, features spanning multiple application layers) direct execution falls short.&lt;/p&gt;
&lt;p&gt;Ariadna adds what's missing: structure.&lt;/p&gt;
&lt;p&gt;The workflow is built around a three-step cycle: plan, execute, verify. Each phase produces detailed plans with Rails conventions baked in. Execution spawns specialised agents in parallel (backend, frontend, testing), each with its own context window. And verification doesn't check whether tasks were completed — it checks whether the phase goal was actually achieved.&lt;/p&gt;
&lt;p&gt;What changes compared to using Claude Code directly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Persistent memory across sessions. A STATE.md file tracks decisions, progress, and blockers.&lt;/li&gt;
&lt;li&gt;Specialised agents that automatically load Rails guides based on the plan's domain.&lt;/li&gt;
&lt;li&gt;Wave-based parallel execution, where plans without dependencies run simultaneously.&lt;/li&gt;
&lt;li&gt;Conversational verification that evaluates results against objectives, not against a task checklist.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is not vibe coding. You get the best results when you review both the generated plans and the outcome of each phase. It's a tool for complex work that requires human judgment at every step.&lt;/p&gt;
&lt;p&gt;It works for greenfield projects and existing applications alike. The map-codebase command analyses the codebase before starting, and you can work with full milestones or quick tasks that maintain the same guarantees.&lt;/p&gt;
&lt;p&gt;Open source, MIT license.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/jorgegorka/ariadna"&gt;Ariadna&lt;/a&gt;&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="AI Engineering"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/186</id>
    <published>2026-01-20T00:00:00Z</published>
    <updated>2026-03-03T21:34:42Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2026/01/the-bitter-lesson"/>
    <title>The bitter lesson</title>
    <content type="html">&lt;p&gt;Last week I read &amp;quot;The Bitter Lesson&amp;quot; an essay that AI researcher Rich Sutton wrote in 2019.&lt;/p&gt;
&lt;p&gt;&lt;a href="http://www.incompleteideas.net/IncIdeas/BitterLesson.html"&gt;http://www.incompleteideas.net/IncIdeas/BitterLesson.html&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;His argument is simple and uncomfortable: across 70 years of AI research, the approaches that won were never the clever, hand-crafted ones built on human expertise. They were the brute-force methods that just threw more computation at the problem. Chess, Go, speech recognition, computer vision — every time, the &amp;quot;elegant&amp;quot; human-knowledge approach lost to the one that simply searched and learned at scale.&lt;/p&gt;
&lt;p&gt;I think web development is next.&lt;/p&gt;
&lt;p&gt;We've spent decades refining our craft. Design patterns, architectural principles, clean code philosophies, framework wars. We take pride in writing elegant solutions. And we should — it's been the right approach when humans are the ones writing and maintaining every line.&lt;/p&gt;
&lt;p&gt;But an AI writing code doesn't need to be a craftsman. It doesn't need to intuit the &amp;quot;right&amp;quot; pattern on the first try. It can generate dozens of possible implementations, test them, evaluate trade-offs, and pick the most adequate one. Not because it understood the problem deeply, but because computation is cheap and getting cheaper.&lt;/p&gt;
&lt;p&gt;It's brute force. And if Sutton's lesson holds, brute force wins.&lt;/p&gt;
&lt;p&gt;This doesn't mean developer expertise becomes worthless overnight. But it does mean the value shifts — from writing the code to defining what &amp;quot;adequate&amp;quot; means. From craftsmanship to judgment.&lt;/p&gt;
&lt;p&gt;The bitter lesson for us might be accepting that the next leap in software quality won't come from a better framework or a cleaner architecture. It'll come from a machine that just tries everything and picks what works.&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="AI Engineering"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/188</id>
    <published>2025-12-27T00:00:00Z</published>
    <updated>2026-03-03T21:51:06Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2025/12/ruby-on-rails-mcp-server-that-provides-a-knowledge-base-for-ai-agents"/>
    <title>Ruby on Rails MCP server that provides a knowledge base for AI agents</title>
    <content type="html">&lt;p&gt;Along with my son &lt;a href="https://github.com/marioalvareznavarro"&gt;Mario Alvarez Navarro&lt;/a&gt; we have developed an open source application called &lt;a href="https://github.com/jorgegorka/minerva"&gt;Minerva&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It's a Ruby on Rails MCP (Model Context Protocol) server that provides a knowledge base for AI agents. Elevate your AI applications with enhanced reasoning and dynamic tool usage through RAG-powered document retrieval.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#what-is-minerva" aria-hidden="true" class="anchor" id="what-is-minerva"&gt;&lt;/a&gt;What is Minerva?&lt;/h2&gt;
&lt;p&gt;Minerva is a self-hosted knowledge base that connects directly to AI agents like Claude, Cursor, or any MCP-compatible tool. You feed it your documents and it makes them available to AI through semantic search, so your AI assistant can reason with your own data.&lt;/p&gt;
&lt;p&gt;Instead of copy-pasting context into every conversation, Minerva lets your AI agent pull exactly the information it needs, when it needs it.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#features" aria-hidden="true" class="anchor" id="features"&gt;&lt;/a&gt;Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Document Management&lt;/strong&gt; - Create and organise markdown documents via an intuitive web UI.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PDF Processing&lt;/strong&gt; - Upload PDFs with automatic text extraction. Drop a PDF and Minerva handles the rest.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Website Scraping&lt;/strong&gt; - Import content from web pages and crawl entire sites to build your knowledge base.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RAG Search&lt;/strong&gt; - Vector similarity search powered by pgvector embeddings. Minerva finds the most relevant documents for any query.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MCP Interface&lt;/strong&gt; - Connect directly to Claude, Cursor, or any MCP-compatible AI agent. One endpoint, zero friction.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;&lt;a href="#architecture" aria-hidden="true" class="anchor" id="architecture"&gt;&lt;/a&gt;Architecture&lt;/h2&gt;
&lt;p&gt;We built Minerva with a focus on simplicity and minimal dependencies:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PostgreSQL with pgvector&lt;/strong&gt; for vector storage (768-dimensional embeddings with HNSW indexing)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ollama&lt;/strong&gt; for local embedding generation using the nomic-embed-text model — no external API calls needed&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Solid Queue / Solid Cache / Solid Cable&lt;/strong&gt; for background jobs and caching — no Redis required&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Propshaft + importmap-rails&lt;/strong&gt; for assets — no Node.js required&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kamal&lt;/strong&gt; for deployment&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The entire stack runs on Rails 8 with PostgreSQL. That's it. No Redis, no Node.js, no complex infrastructure.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#how-it-works" aria-hidden="true" class="anchor" id="how-it-works"&gt;&lt;/a&gt;How it works&lt;/h2&gt;
&lt;p&gt;Minerva exposes a single &lt;code&gt;/mcp&lt;/code&gt; endpoint that AI agents connect to. When an agent needs information, it calls the &lt;strong&gt;DocumentSearch&lt;/strong&gt; tool through MCP, and Minerva returns the most relevant documents from your knowledge base using vector similarity search.&lt;/p&gt;
&lt;p&gt;Setting it up with Claude Code is as simple as pointing to your Minerva instance:&lt;/p&gt;
&lt;pre lang="json" style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#c0c5ce;"&gt;{
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;mcpServers&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;: {
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;minerva&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;: {
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;      &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;url&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;: &amp;quot;&lt;/span&gt;&lt;span style="color:#a3be8c;"&gt;http://localhost:3000/mcp&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;&amp;quot;
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;    }
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;  }
&lt;/span&gt;&lt;span style="color:#c0c5ce;"&gt;}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;a href="#open-source" aria-hidden="true" class="anchor" id="open-source"&gt;&lt;/a&gt;Open Source&lt;/h2&gt;
&lt;p&gt;Minerva is fully open source under the MIT license. You can find the code, installation instructions, and documentation on GitHub:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/jorgegorka/minerva"&gt;github.com/jorgegorka/minerva&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Contributions are welcome!&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="AI Engineering"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/191</id>
    <published>2025-11-25T00:00:00Z</published>
    <updated>2026-03-03T21:58:02Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2025/11/ides-are-dead"/>
    <title>IDEs are dead</title>
    <content type="html">&lt;p&gt;I hear a lot lately that 2026 is the year where the IDE dies. LLMs will be so good that they will generate all the code by themselves and developers will become a mix of product managers, architects and code reviewers.&lt;/p&gt;
&lt;p&gt;Creating code has never been a goal by itself. The features that the code creates, solving problems for those using our software, that's the goal of every (valuable) line of code.&lt;/p&gt;
&lt;p&gt;Opus 4.5 is an impressive LLM the quality of its code and its planning capabilities are astonishing. Cursor's composer and GPT codex are also very good. Based on the evolution of the last 6 months we'll have very capable LLMs in no time.&lt;/p&gt;
&lt;p&gt;But that approach still leaves us with a big unsolved problem. It's us that still need to provide all business decisions for the LLM.&lt;/p&gt;
&lt;p&gt;The real revolution, the big shift in how we develop software will come the day we can talk to LLMs about our business.&lt;/p&gt;
&lt;p&gt;It is only when the LLMs understand our business that they can start asking the right questions when we ask for a new feature or a bug fix.&lt;/p&gt;
&lt;p&gt;I can see this as a big change of paradigm same as with the autonomous car. The real change in autonomous cars is not that they can drive unattended. The change is that because they can drive unattended, I don't need to buy a car anymore, I will use an app to request one when I need it and it will take me to my destination and then leave to serve another person. That completely reshapes the automotive industry.&lt;/p&gt;
&lt;p&gt;It's the same with LLMs. Context engineering, the current tools… they are focused on the code and we should focus on the problems that we are trying to solve with that code.&lt;/p&gt;
&lt;p&gt;That's when the big shift will come.&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="AI Engineering"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/189</id>
    <published>2025-11-11T00:00:00Z</published>
    <updated>2026-03-03T21:54:04Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2025/11/testing-in-the-age-of-ai"/>
    <title>Testing in the Age of AI</title>
    <content type="html">&lt;p&gt;One of the first things I integrated into my development workflow with AI was writing tests.&lt;/p&gt;
&lt;p&gt;AIs are remarkably good at this. Give them context about your codebase, and they'll generate comprehensive test suites faster than you can write a single spec file.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-overtesting-problem" aria-hidden="true" class="anchor" id="the-overtesting-problem"&gt;&lt;/a&gt;The &amp;quot;overtesting&amp;quot; problem&lt;/h2&gt;
&lt;p&gt;There's a recurrent pattern: &amp;quot;AI tends to overtest.&amp;quot; Too many assertions, edge cases you'd never bother with manually...&lt;/p&gt;
&lt;p&gt;Before AI, this was a legitimate concern. Every test you write is a test you have to maintain. But if the AI writes the tests and maintains them, and your role becomes supervisory—reviewing, approving, and steering—then a bit of &amp;quot;overtesting&amp;quot; is not a big deal. The economics of quality have changed.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#if-you-use-claude-code-create-a-testing-skill" aria-hidden="true" class="anchor" id="if-you-use-claude-code-create-a-testing-skill"&gt;&lt;/a&gt;If you use Claude Code, create a &amp;quot;Testing Skill&amp;quot;&lt;/h2&gt;
&lt;p&gt;This is the secret to scaling consistency. Here's my approach to building one:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Audit your patterns:&lt;/strong&gt; Let Claude examine your existing tests first so it can replicate your specific style.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Define fixture strategy:&lt;/strong&gt; Be explicit about when to use fixtures vs. creating fresh records.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Set stub boundaries:&lt;/strong&gt; Define what's okay to stub (External APIs? DB calls?).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Time-travel rules:&lt;/strong&gt; Establish conventions for date/time handling to avoid flaky tests.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Add guardrails:&lt;/strong&gt; Focus on behavior, not implementation details. No testing private methods!&lt;/p&gt;
&lt;h2&gt;&lt;a href="#e2e-testing-just-became-accessible" aria-hidden="true" class="anchor" id="e2e-testing-just-became-accessible"&gt;&lt;/a&gt;E2E testing just became accessible&lt;/h2&gt;
&lt;p&gt;With Claude Code's browser integration, complex E2E tests are now just a natural language prompt away:&lt;/p&gt;
&lt;p&gt;&amp;quot;Login as &lt;a href="mailto:employee@test.com"&gt;employee@test.com&lt;/a&gt;, create 3 items from different categories, assign them to different offices, and generate an items export report to CSV.&amp;quot;&lt;/p&gt;
&lt;p&gt;That's the whole spec. What used to be painful to maintain is now a simple conversation.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#conclusion" aria-hidden="true" class="anchor" id="conclusion"&gt;&lt;/a&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Improving test coverage and simplifying maintenance is the &amp;quot;low-hanging fruit&amp;quot; of AI integration. It's low risk, high visibility, and provides immediate value to any team.&lt;/p&gt;
&lt;p&gt;Just make sure everyone shares the same Testing Skill and context. Consistency is what makes this scale.&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="AI Engineering"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/190</id>
    <published>2025-11-05T00:00:00Z</published>
    <updated>2026-03-03T21:56:18Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2025/11/claude-code-configuration"/>
    <title>Claude Code configuration</title>
    <content type="html">&lt;p&gt;After months of using Claude Code, I've found a configuration that's improved my development workflow.&lt;/p&gt;
&lt;p&gt;The key? Separating roles from business logic.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#my-approach-skills--roles-commands--business-logic" aria-hidden="true" class="anchor" id="my-approach-skills--roles-commands--business-logic"&gt;&lt;/a&gt;My approach: Skills = Roles, Commands = Business Logic&lt;/h2&gt;
&lt;p&gt;I created dedicated skills for each role: Developer, Reviewer, Refactor, Designer, Product Manager.&lt;/p&gt;
&lt;p&gt;Each skill contains role-specific context: best practices, patterns, architecture guidelines, dos and don'ts. Claude actually helped me create these—I just supervised and refined the content.&lt;/p&gt;
&lt;p&gt;All domain-specific logic lives in commands. For example, my &amp;quot;Employees&amp;quot; command includes the relevant models, relationships, permissions, and roles.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#what-makes-it-work" aria-hidden="true" class="anchor" id="what-makes-it-work"&gt;&lt;/a&gt;What makes it work&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Explicit skill loading&lt;/strong&gt; - I always load the appropriate skill before starting an assignment.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Plan mode for complex tasks&lt;/strong&gt; - Claude creates a detailed task list before diving in.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Context management&lt;/strong&gt; - I clear context around 75% capacity and re-feed the skill, command, and task list (both completed and pending).&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-results" aria-hidden="true" class="anchor" id="the-results"&gt;&lt;/a&gt;The results?&lt;/h2&gt;
&lt;p&gt;Using Claude Code (Opus 4.5) with this setup has been transformative. The code quality is consistently high, and the structured approach creates predictable results.&lt;/p&gt;
&lt;p&gt;What's your experience with Claude Code?&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="AI Engineering"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/126</id>
    <published>2018-05-09T00:00:00Z</published>
    <updated>2025-07-28T21:36:06Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2018/05/why-regular-feedback-is-so-important-for-remote-teams"/>
    <title>Why regular feedback is so important for remote teams</title>
    <content type="html">&lt;p&gt;I've written a post for &lt;a href="https://www.happymoodscore.com"&gt;Happy Mood Score&lt;/a&gt;. It's called: Why regular feedback is so important for remote users.&lt;/p&gt;
&lt;p&gt;You can find advice on topics like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Non-verbal communication&lt;/li&gt;
&lt;li&gt;Encourage collaboration&lt;/li&gt;
&lt;li&gt;Focus on achievements not hours&lt;/li&gt;
&lt;li&gt;Avoid the &amp;quot;Green dot syndrome&amp;quot;&lt;/li&gt;
&lt;li&gt;Ask how your employees feel as much as what they are doing.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can read the article here:&lt;a href="https://www.happymoodscore.com/blog/files/Why-regular-feedback-is-so-important-for-remote-teams.html"&gt;Why regular feedback is so important for remote users.&lt;/a&gt;&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="business"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/125</id>
    <published>2018-04-05T00:00:00Z</published>
    <updated>2025-07-28T21:36:06Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2018/04/how-the-size-of-a-company-affects-developers"/>
    <title>How the size of a company affects developers</title>
    <content type="html">&lt;p&gt;I've been working in companies of different sizes both as a freelancer, employee and founder and although logic dictates that the size of the company will affect the way you work, I've seen developers that always try to work in the same way.You should adapt to the constrains and advantages that the company's size imposes on you.&lt;/p&gt;
&lt;p&gt;Let's see some differences and how they impact the way you work.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-cost-of-delay" aria-hidden="true" class="anchor" id="the-cost-of-delay"&gt;&lt;/a&gt;The cost of delay&lt;/h2&gt;
&lt;p&gt;A small company is usually constrained by limited financial resources. That is not good nor bad. It's a fact and you should take it into account.&lt;/p&gt;
&lt;p&gt;If a project is delayed the costs of that delay may jeopardise not only the project but the hole company. Small companies do not usually have the financial resources big companies have and therefore any delay is specially harmful for them. Yes, I know, that puts some pressure on you but that is fine as long as you are aware of that. In small companies or startups good enough is better than perfect. Do not strive for perfection, just get things done!.&lt;/p&gt;
&lt;p&gt;Small companies should only hire &amp;quot;full-stack&amp;quot; developers. People that can help them ship code. One sprint you focus on backend development and the next sprint you focus on frontend development. Your flexibility, your ability to wear different hats is really beneficial for the company and will help it to achieve their goals.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-cost-of-change" aria-hidden="true" class="anchor" id="the-cost-of-change"&gt;&lt;/a&gt;The cost of change&lt;/h2&gt;
&lt;p&gt;W &lt;strong&gt;hen you work for a big company with tens or hundreds of developers you should take into account the cost of change&lt;/strong&gt;. The way you name variables, best practices, how you review code and how you merge it. All must be know beforehand by everyone involved and that has a cost.&lt;/p&gt;
&lt;p&gt;Changing your ticket application can be very costly both in time and money if you have 200 developers. Creating accounts, migrating information, teaching the tool... That cost is negligible in a small company,&lt;/p&gt;
&lt;p&gt;Even small details like how you name variables or how to test your code have a big impact on big companies where all developers should follow the same best practices.&lt;/p&gt;
&lt;p&gt;Good thing is that time is not a constraint, sure there are deadlines, but their impact is not as acute as in small companies, A two weeks delay in a 3 months project is not a big deal in a big company and may have a dangerous impact in a small company.&lt;/p&gt;
&lt;p&gt;In may cases a small delay is desirable if that helps the project to follow all best practices that the company imposes to itself. Your role as a developer must be to generate very high quality code. Measure twice, cut once. Once some code is merged into the codebase it's exposed to all your colleagues and it won't be easy to modify later on. Your classes and methods will be used by your colleagues and any change to them will probably have deeper implications and side effects.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;In big companies roles should be very clear from the very beginning&lt;/strong&gt; and a separation between backend and frontend roles is really beneficial. It helps you as a developer to focus on a specific part of the application and makes it easier for you to apply and follow the rules and best practices.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#the-cost-of-onboarding" aria-hidden="true" class="anchor" id="the-cost-of-onboarding"&gt;&lt;/a&gt;The cost of onboarding&lt;/h2&gt;
&lt;p&gt;This cost is frequently overlooked and underestimated. &lt;strong&gt;Having a new person up-to-speed and productive is not easy&lt;/strong&gt;. In small companies the biggest hurdle is not technical but making the new developer understand the business logic of the company. In a big company you don't have that problem because people work in teams where the business logic is constrained but on the other side there are many rules and best practices that you need to learn so it will also take some time for the new developer to become productive.&lt;/p&gt;
&lt;p&gt;I've frequently found that the onboarding process of the companies I've worked for (no matter the size) is focused on technical aspects with a quick view of the company business model. &lt;strong&gt;I think small companies should pay more attention and explain their business model to new employees because it has a big impact on their productivity.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;On the other side the cost of losing talent is bigger in a small company not only because the technical skills but also because losing someone that really knows our company, how it works, its goals and objectives makes it very difficult to replace even knowing that the onboarding process is quicker in a small company. Big companies can afford lose talent without risking as much as small companies but it's something they should avoid as well because knowing the &amp;quot;company culture&amp;quot; is an important asset.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#agile-vsefficient" aria-hidden="true" class="anchor" id="agile-vsefficient"&gt;&lt;/a&gt;Agile vs.efficient&lt;/h2&gt;
&lt;p&gt;We all know a lot of big companies calling themselves &amp;quot;agile&amp;quot; but I've never really seen one that truly is.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Big companies can be efficient but can never be agile.&lt;/strong&gt; Only small companies can be agile. That is a big advantage for them if they know how to use it.&lt;/p&gt;
&lt;p&gt;A startup can (and should) pivot and change its strategy. They can do that in less than one week. That is impossible to do for a big company where you need to coordinate a lot of people. That is the truly definition of agile: The ability of changing your mind and having a team and a process that helps you do that.&lt;/p&gt;
&lt;p&gt;You can pivot and change strategy in a big company but you need a lot of planning and that takes time. The more people you have the more time you need to pivot. And you can do all of that change in a very efficient and optimised way but never agile.&lt;/p&gt;
&lt;h2&gt;&lt;a href="#summary" aria-hidden="true" class="anchor" id="summary"&gt;&lt;/a&gt;Summary&lt;/h2&gt;
&lt;p&gt;As developers we need to be aware of the constrains and advantages of the different sizes of companies.&lt;/p&gt;
&lt;p&gt;I've worked with some developers that try to work the same way whether they are working for a big or a small company. I think that is a big mistake that can lead to frustration both for the developer and the company. You need to be flexible and agile and adapt the way you work and the way you code to the needs of the company you are working for.&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="business"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/124</id>
    <published>2017-03-16T00:00:00Z</published>
    <updated>2025-07-28T21:36:06Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2017/03/do-you-know-how-your-customers-use-your-product"/>
    <title>Do you know how your customers use your product?</title>
    <content type="html">&lt;p&gt;When you create your own startup and have a technological background, as I do, is very easy to think that what your customers need is a product full of features. But what your customers need is a product that makes their lives easier, period.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Having a product with the right features is more important than having a product with a lot of features.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That is why I spend a few hours every week looking at the usage patterns of &lt;a href="https://www.happymoodscore.com"&gt;Happy Mood Score&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;To know how people are using HMS I use Google Analytics and Events.&lt;/p&gt;
&lt;p&gt;&lt;img src="http://static1.squarespace.com/static/5303797ae4b0c6ad9e43f072/5303ce80e4b0400995a883d6/58e6712e46c3c414863a55db/1491497281163/Total+event+distribution.jpgTotal+event+distribution?format=original" alt="" /&gt;&lt;/p&gt;
&lt;p&gt;The firs step is to define what I want to know. The most important thing I want to track in Happy Mood Score is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Feedback&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Peer to peer rewards&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Ideas&lt;/p&gt;
&lt;p&gt;Then there are other metrics that I want to know also like: adding new employees, 1on1 notes or teams.&lt;/p&gt;
&lt;p&gt;All those metrics will tell me important information about the use of HMS. &lt;strong&gt;To capture that information I use a Google Analytics feature called: Events&lt;/strong&gt;. In Google Analytics adding an event is very easy. You need to add the following line of code:&lt;/p&gt;
&lt;pre style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#c0c5ce;"&gt;ga(&amp;#39;send&amp;#39;, &amp;#39;event&amp;#39;, &amp;#39;Category&amp;#39;, &amp;#39;Action&amp;#39;, &amp;#39;Label&amp;#39;, Value);
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For instance: Let's say that I want to track when someone creates a new High 5 (&lt;a href="https://www.happymoodscore.com/features/"&gt;High 5s are peer to peer rewards&lt;/a&gt; in Happy Mood Score). I would add the following event after the High 5 has been created successfully.&lt;/p&gt;
&lt;pre style="background-color:#2b303b;"&gt;&lt;code&gt;&lt;span style="color:#c0c5ce;"&gt;ga(&amp;#39;send&amp;#39;, &amp;#39;event&amp;#39;, &amp;#39;High5&amp;#39;, &amp;#39;New&amp;#39;, &amp;#39;UserDashboard&amp;#39;, 0);
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Category:&lt;/strong&gt; I am tracking which tool is being used, in this case the High 5.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Action:&lt;/strong&gt; I am saving the specific action the user is doing like: New, Edit, Delete.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Label:&lt;/strong&gt; Where is the user doing that. Users can give a High 5 to other users in different parts of the application. Now I know which place is being used the most.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Value:&lt;/strong&gt; This is a numeric field so I use it to know if the High 5 has a message included. If that is the case then it will be 1 or 0 if no message is present.&lt;/p&gt;
&lt;p&gt;As you can see there is a lot of interesting information that I know using events.&lt;/p&gt;
&lt;p&gt;Combine this information with some custom metrics like, for instance, if the company is in the trial period or not, and you will have a lot of useful information to make informed decisions.&lt;/p&gt;
&lt;p&gt;Now I can create custom reports that will tell me:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;How many new employees are adding companies in the trial period?&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;How many High 5s are created each week/month?&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Which areas of the application are being used the most/least?&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Which day do people create more feedback, High 5s, ideas?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;There is also a report called &amp;quot;Events Flow&amp;quot; where I can learn things like:&lt;/p&gt;
&lt;p&gt;&lt;img src="http://static1.squarespace.com/static/5303797ae4b0c6ad9e43f072/5303ce80e4b0400995a883d6/58e670ff197aea71b4597873/1491497226875/event+flow.jpgevent+flow?format=original" alt="" /&gt;&lt;/p&gt;
&lt;p&gt;After reporting their status 50% of the people send a feedback message and then 16% of that people send an average of 2 High 5s to co-workers.&lt;/p&gt;
&lt;p&gt;How cool is that? &lt;strong&gt;A lot of information available with a small effort&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;I know there are other tools available, like Mixpanel, but I found them very expensive specially for a &lt;a href="https://www.alvareznavarro.es/blog/2017/2/why-every-startup-should-start-bootstrapped"&gt;bootstrapped startup&lt;/a&gt; like us.&lt;/p&gt;
&lt;p&gt;Are you tracking the usage of your product? I would love to know how you do it and any tips, improvements or suggestions.&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="business"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/123</id>
    <published>2017-02-23T00:00:00Z</published>
    <updated>2025-07-28T21:36:06Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2017/02/why-every-startup-should-start-bootstrapped"/>
    <title>Why every startup should start bootstrapped</title>
    <content type="html">&lt;p&gt;When I started Happy Mood Score 2 years ago I made the decision that it should be a bootstrapped startup.&lt;/p&gt;
&lt;p&gt;I had a vision in my mind: Create a &lt;a href="https://www.happymoodscore.com/"&gt;team management tool that team managers and HR departments would love&lt;/a&gt;. And I knew that in order to achieve that vision I should bootstrap.&lt;/p&gt;
&lt;p&gt;There are two kind of investments: Seed investment and Series A, B, C,... investment.&lt;/p&gt;
&lt;p&gt;Seed investment is used to create the startup: Hire employees, develop a product, define buyers and create a marketing strategy.&lt;/p&gt;
&lt;p&gt;Series A, B, C... investment is needed when a startup has a solid idea, has found a business model and wants to grow, expand, acquire competitors, etc.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;If you get money from investors early (seed stage) you also give them the ability to influence the company's decisions.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;If you get money from investors early (seed stage) you also give them the ability to influence the company's decisions. Your vision and your goals are not what matter anymore. Now it's a shared vision and shared goals.&lt;/p&gt;
&lt;p&gt;When the time is right (series A, B...) sharing that vision and goals with investors can boost your company. It can make a big difference. But when a company is starting it is almost never the right time.&lt;/p&gt;
&lt;p&gt;You need to be free to make the right decisions. You need to be free to make the wrong decisions. To pivot, to change, to experiment, to evolve, to learn.&lt;/p&gt;
&lt;p&gt;In this two years &lt;strong&gt;my vision and reality have clashed many times&lt;/strong&gt;. I've learned from my mistakes. Happy Mood Score is not the product I'd developed two years ago. It has evolved, it has improved. I have also evolved and improved. We are both better than two years ago.&lt;/p&gt;
&lt;p&gt;I'd never have had the opportunity to do so with investors joining the company from the very beginning.&lt;/p&gt;
&lt;p&gt;There are so many hats I have to wear now. Developer, marketing, customer support, accountant. But I kinda like it. I know it doesn't scale and that it will have to change someday but I'm enjoying every minute of it while it last.&lt;/p&gt;
&lt;p&gt;We are two people working full time on Happy Mood Score now. I do all the customer support myself. It's one of the things that I like the most. Talking to customers, people using HMS, knowing what they like, what they don't...&lt;/p&gt;
&lt;p&gt;I'm really proud with my current marketing strategy. &lt;strong&gt;Online advertising is very expensive&lt;/strong&gt; : LinkedIn, AdWords, Facebook Ads, Twitter Ads. They cost a fortune (I'll write about this in another post). So I had to be creative and use other approaches that do not involve spending $1.000/month in each of those channels.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;With 20 employees you need to be the CEO 100% of your time&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;If you start with seed capital and you have 20 employees from the very beginning you may grow faster and you may launch a big product from the start. But with 20 employees you need to be the CEO 100% of your time. Unfortunately it involves losing contact with your customers and your product the moment your company and your vision need it the most.&lt;/p&gt;
&lt;p&gt;It could be worse. &lt;strong&gt;You may hate being the CEO.&lt;/strong&gt; All of a sudden you spend all your time managing employees, forecasts and investors. If you are a doer it can be very frustrating.&lt;/p&gt;
&lt;p&gt;In a bootstrapped company you actually do things. You get your hands dirty from the start. When the company grows and you find yourself doing more management work than actual work then you can decide to step forward and take the role or hire a CEO. That could be a good moment for finding investors. A good investor will not only give you money but also advice on how to move into the future.&lt;/p&gt;
&lt;p&gt;A bootstrapped startup can be harder to evolve than a seeded startup but the journey is very rewarding, you will learn a lot of things and you are going to love every single minute of it.&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="business"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/122</id>
    <published>2017-02-12T00:00:00Z</published>
    <updated>2025-07-28T21:36:06Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2017/02/would-you-spend-1-5-million-in-a-domain-name"/>
    <title>Would you spend $1.5 million in a domain name?</title>
    <content type="html">&lt;p&gt;I've been following sumome.com for the last year or so. I even use it on &lt;a href="https://www.happymoodscore.com"&gt;Happy Mood Score&lt;/a&gt; to get subscribers to our monthly newsletter about employee management, feedback, engagement, remote employees and digital nomads.&lt;/p&gt;
&lt;p&gt;I must confess I was very surprised when they announced that they spent $1.5 million to buy sumo.com. That is a lot of money for a generic domain.&lt;/p&gt;
&lt;p&gt;Noah Kagan, the founder of &lt;a href="http://sumome.com/"&gt;SumoMe&lt;/a&gt; has a podcast explaining &lt;a href="http://okdork.com/what-i-learned-spending-1-5-million-on-sumo-com/"&gt;the lessons learned&lt;/a&gt; spending that ludicrous amount of money on a domain. Also there is &lt;a href="http://sumome.com/sumo-name-change"&gt;an infographic about how expensive the domain was&lt;/a&gt;. &lt;em&gt;Hint: You can buy 28,301 sumo belts with that money.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;These are the reasons why I would never spend so much money on a domain like sumo.com:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It's a word that is unrelated to your business relevant keywords.&lt;/li&gt;
&lt;li&gt;The sport (Sumo) is really well known all over the planet. Sumo has a strong association for most of us and that association is big guys slapping and pushing each other's out of a ring. Do not forget about throwing salt. :-)&lt;/li&gt;
&lt;li&gt;I reckon that for much less than the cost of sumo.com they could have renamed the company, buy a cheap .com and spend a few hundred of thousands advertising the rebranding.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I wonder how many visitors they will get that are looking for some information about Sumo (the sport) and end up visiting that domain.&lt;/p&gt;
&lt;p&gt;Noah Kagan is a very smart guy. He did an amazing job at Mint so what I won't do is to criticise or underestimate him. I'm not saying that he's wrong only that I would have put my branding money in another place.&lt;/p&gt;
&lt;p&gt;Now let's wait and see what the ROI of this huge buy is. I'm sure he'll write a post about it in due time.&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="business"/>
  </entry>
  <entry>
    <id>tag:alvareznavarro.es,2005:Article/121</id>
    <published>2016-06-21T00:00:00Z</published>
    <updated>2025-07-28T21:36:06Z</updated>
    <link rel="alternate" type="text/html" href="https://alvareznavarro.es/blog/2016/06/peer-to-peer-rewards-as-a-way-of-recognition-at-work"/>
    <title>peer-to-peer rewards as a way of recognition at work</title>
    <content type="html">&lt;p&gt;&lt;a href="https://www.happymoodscore.com/?utm_source=medium&amp;amp;utm_medium=blog&amp;amp;utm_campaign=p2p"&gt;Peer-to-peer rewards&lt;/a&gt; are the best form of recognition at work. Well, maybe not the best but one of the best. It is in the top 3, that’s for sure.&lt;/p&gt;
&lt;p&gt;In most companies recognition usually comes “from above” and that is good, we all love that our boss/manager recognise our hard work and commitment. That is part of your job as a boss because &lt;strong&gt;to be a good leader you must acknowledge the work of your team members&lt;/strong&gt;. It is mandatory and if you don’t do that then you are not a good boss, period.&lt;/p&gt;
&lt;p&gt;But when you receive recognition from one of your co-workers then things change, you feel proud and happy. Your self-esteem increases as do your sense of belonging to the team. And that is because your co-workers don’t need to reward you for your hard work. &lt;strong&gt;They have no obligation, it is entirely optional and that is the main reason that it feels really great when they do.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If you are a digital nomad or you work on a distributed team there is the risk of feeling disconnected. You work as an individual and your sense of belonging to a team can be very low. In these environments having a peer-to-peer reward system is very important.&lt;/p&gt;
&lt;p&gt;The simple act of sending a virtual High 5 to one of your colleagues can have a big impact on both of you. It will increase your involvement with the team and will increase the self-esteem of the receiver. &lt;strong&gt;It is the best way to recognise the work of those team members who are always ready to help, good team players, good co-workers…&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Happy Mood Score has peer-to-peer rewards available for everybody, we call them High 5.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Don’t reward the rewards&lt;/p&gt;
&lt;p&gt;A question that frequently arise when using Happy Mood Score Hi 5 is: &lt;em&gt;when I receive a High 5 from one of my co-workers, why I can’t see who gave it to me?&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The reason is very simple. From my experience implementing reward systems on companies, people who receive a High 5 tend to give another High 5 to the person that sent it as a way of saying thank you.&lt;/p&gt;
&lt;p&gt;That is not a good use of a peer-to-peer system and must be avoided. If you don’t know who send a High 5 to you, you can’t, obviously, give another in return. This measure will increase the efficiency of the system in the long term.&lt;/p&gt;
&lt;p&gt;If you are a team manager, work for the human resources department or you are thinking about implementing a &lt;a href="https://www.happymoodscore.com/?utm_source=medium&amp;amp;utm_medium=blog&amp;amp;utm_campaign=p2p"&gt;peer-to-peer reward system&lt;/a&gt; at your company there is no better tool than &lt;a href="https://www.happymoodscore.com/?utm_source=medium&amp;amp;utm_medium=blog&amp;amp;utm_campaign=p2p"&gt;Happy Mood Score&lt;/a&gt;.&lt;/p&gt;
</content>
    <author>
      <name>Jorge Alvarez</name>
    </author>
    <category term="business"/>
  </entry>
</feed>
