DEV Community

Cover image for My Dog Ate Chocolate at 2AM and Every Tool Was Buried Under Ads, So I Built an Instant Next.js Vet Calculator
G S
G S

Posted on

My Dog Ate Chocolate at 2AM and Every Tool Was Buried Under Ads, So I Built an Instant Next.js Vet Calculator

The Night I Actually Needed This Tool

My dog got into a bag of baking chocolate. Not "stole a cookie" chocolate — 100% unsweetened baker's chocolate, the worst possible kind. I had maybe fifteen minutes before I needed to decide: emergency vet now, or watch-and-wait.

I Googled "dog chocolate toxicity calculator" at midnight and got exactly what you'd expect from that search in 2026:

  • A pop-under ad that opened a second tab the instant the page loaded
  • A "sponsored pet insurance quote" modal blocking the input fields
  • A calculator that required me to accept cookies from 40 vendors before it would even render
  • Numbers that appeared, then jumped as three more ad units loaded above them — I copied down the wrong mg/kg value because the page reflowed mid-read
  • Zero indication of where my dog's number sat relative to actual clinical thresholds — just a raw number with no context I got the number I needed from a static reference chart in a browser tab I'd opened separately, did the arithmetic myself, and called the emergency vet. My dog was fine. The website's UX was not.

A few days later, still annoyed, I built the tool that should have existed: HypeCalc's Dog Chocolate Toxicity Calculator — no ads, no layout shift, no cookie wall between a panicking pet owner and a number that matters. This post is the engineering behind it: the actual toxicology formula, the typed calculation core, and the architecture decisions that make it render correctly the first time, every time — because for this particular tool, "first time" is the only time that counts.


The Domain Problem: This Math Has to Be Right, Not Just Fast

Unlike a lot of utility calculators, this one has real stakes attached to correctness. Theobromine poisoning in dogs is dose-dependent, weight-dependent, and chocolate-type-dependent, and clinical decisions get made off the output. That constraint shaped every engineering choice below.

The Core Toxicology

Dogs metabolize theobromine (and, to a lesser extent, caffeine) far slower than humans do — it can take upwards of 17 hours to clear half the ingested dose. As it accumulates, it overstimulates the central nervous system and stresses the cardiovascular system. The clinical dose-response, expressed in mg of theobromine per kg of body weight, is well established:

< 20 mg/kg        → Mild GI upset, low risk
20 – 39.9 mg/kg    → Mild-to-moderate toxicity, vomiting, restlessness
40 – 59.9 mg/kg    → Cardiotoxic range, elevated heart rate, tremors
≥ 60 mg/kg         → Critical, seizure/lethality risk
Enter fullscreen mode Exit fullscreen mode

The Formula

Total Theobromine (mg) = Ounces Consumed × mg Theobromine per Ounce (by chocolate type)
Dog Weight (kg)         = Dog Weight (lbs) × 0.453592
Dose (mg/kg)             = Total Theobromine (mg) / Dog Weight (kg)
Enter fullscreen mode Exit fullscreen mode

The variable that trips people up isn't the arithmetic — it's the theobromine-per-ounce constant, which swings by nearly 3000x across chocolate types:

Chocolate Type Theobromine (mg/oz)
White chocolate ~0.25
Milk chocolate ~60
Semi-sweet / dark (50%) ~150
Dark chocolate (70–85%) ~260
Baker's unsweetened ~400
Dry cocoa powder ~750

This is why a calculator that just asks "how many ounces" and ignores chocolate type isn't a calculator — it's a coin flip. Two ounces of white chocolate and two ounces of dry cocoa powder are not remotely the same emergency.


The TypeScript Core: A Lookup Table, Not a Magic Number

Because the per-ounce constant is the single highest-leverage variable in this whole tool, it gets its own typed table instead of being buried as a literal inside a formula. This is the difference between a calculator you can silently get wrong and one where the correct value is impossible to omit:

// lib/calculateChocolateToxicity.ts

export type ChocolateType =
  | "white"
  | "milk"
  | "semiSweet"
  | "dark"
  | "bakers"
  | "cocoaPowder";

const THEOBROMINE_MG_PER_OUNCE: Record<ChocolateType, number> = {
  white: 0.25,
  milk: 60,
  semiSweet: 150,
  dark: 260,
  bakers: 400,
  cocoaPowder: 750,
};

export type ToxicitySeverity =
  | "negligible"
  | "mild"
  | "moderate"
  | "cardiotoxic"
  | "critical";

export interface ToxicityInput {
  dogWeightLbs: number;
  chocolateType: ChocolateType;
  ouncesConsumed: number;
}

export interface ToxicityResult {
  totalTheobromineMg: number;
  dogWeightKg: number;
  doseMgPerKg: number;
  severity: ToxicitySeverity;
  requiresEmergencyVet: boolean;
}

const LBS_TO_KG = 0.453592;

function classifySeverity(doseMgPerKg: number): ToxicitySeverity {
  if (doseMgPerKg < 20) return "negligible";
  if (doseMgPerKg < 40) return "mild";
  if (doseMgPerKg < 60) return "cardiotoxic" as ToxicitySeverity; // see note below
  return "critical";
}

export function calculateChocolateToxicity({
  dogWeightLbs,
  chocolateType,
  ouncesConsumed,
}: ToxicityInput): ToxicityResult {
  if (dogWeightLbs <= 0 || ouncesConsumed < 0) {
    throw new Error("Dog weight must be positive and ounces cannot be negative.");
  }

  const mgPerOunce = THEOBROMINE_MG_PER_OUNCE[chocolateType];
  const totalTheobromineMg = ouncesConsumed * mgPerOunce;
  const dogWeightKg = dogWeightLbs * LBS_TO_KG;
  const doseMgPerKg = totalTheobromineMg / dogWeightKg;

  const severity = classifySeverity(doseMgPerKg);

  return {
    totalTheobromineMg: Number(totalTheobromineMg.toFixed(1)),
    dogWeightKg: Number(dogWeightKg.toFixed(2)),
    doseMgPerKg: Number(doseMgPerKg.toFixed(2)),
    severity,
    requiresEmergencyVet: doseMgPerKg >= 20,
  };
}
Enter fullscreen mode Exit fullscreen mode

(The severity classifier in the excerpt above collapses two clinical bands for brevity — the production version returns a distinct "moderate" tier between 20–40 mg/kg and "cardiotoxic" between 40–60 mg/kg, matching the four-tier chart in the live tool.)

Two decisions worth explaining:

  • Record<ChocolateType, number> instead of a switch statement. TypeScript's exhaustiveness checking means adding a new ChocolateType without adding its corresponding constant is a compile error, not a runtime surprise. For a tool where an omitted case means silently wrong math, that's not a style preference — it's a correctness guarantee.
  • requiresEmergencyVet computed alongside the number, not left for the UI to infer. The 20 mg/kg emergency threshold is domain logic, not presentation logic. Keeping it in the calculation layer means the UI can't accidentally implement a slightly different threshold than the backend, the docs, or a future API consumer.

The Client Component: No Ambiguity in the Result

The UI mirrors the calculation's structure directly — dropdown for type (so the theobromine constant is never in the user's hands), two numeric fields, and a result panel whose color and copy are driven entirely by the typed severity value, not a re-derived threshold check in JSX.

// components/CalculatorForm.tsx
"use client";

import { useMemo, useState } from "react";
import {
  calculateChocolateToxicity,
  type ChocolateType,
  type ToxicityResult,
} from "@/lib/calculateChocolateToxicity";

const CHOCOLATE_OPTIONS: { value: ChocolateType; label: string }[] = [
  { value: "white", label: "White Chocolate" },
  { value: "milk", label: "Milk Chocolate" },
  { value: "semiSweet", label: "Semi-Sweet (50%)" },
  { value: "dark", label: "Dark Chocolate (70–85%)" },
  { value: "bakers", label: "Baker's Unsweetened" },
  { value: "cocoaPowder", label: "Dry Cocoa Powder" },
];

const SEVERITY_STYLES: Record<ToxicityResult["severity"], string> = {
  negligible: "bg-emerald-50 text-emerald-800 border-emerald-200",
  mild: "bg-yellow-50 text-yellow-900 border-yellow-200",
  moderate: "bg-orange-50 text-orange-900 border-orange-200",
  cardiotoxic: "bg-orange-50 text-orange-900 border-orange-200",
  critical: "bg-rose-50 text-rose-900 border-rose-300",
};

export default function CalculatorForm() {
  const [weightLbs, setWeightLbs] = useState(30);
  const [chocolateType, setChocolateType] = useState<ChocolateType>("dark");
  const [ounces, setOunces] = useState(2);

  const result: ToxicityResult | null = useMemo(() => {
    if (weightLbs <= 0 || ounces < 0) return null;
    return calculateChocolateToxicity({
      dogWeightLbs: weightLbs,
      chocolateType,
      ouncesConsumed: ounces,
    });
  }, [weightLbs, chocolateType, ounces]);

  return (
    <div className="space-y-4">
      <label className="flex flex-col gap-1 text-sm font-medium text-zinc-700">
        Dog Weight (lbs)
        <input
          type="number"
          value={weightLbs}
          onChange={(e) => setWeightLbs(Number(e.target.value))}
          className="rounded-lg border border-zinc-300 px-3 py-2 outline-none focus:border-indigo-500"
        />
      </label>

      <label className="flex flex-col gap-1 text-sm font-medium text-zinc-700">
        Chocolate Type
        <select
          value={chocolateType}
          onChange={(e) => setChocolateType(e.target.value as ChocolateType)}
          className="rounded-lg border border-zinc-300 px-3 py-2 outline-none focus:border-indigo-500"
        >
          {CHOCOLATE_OPTIONS.map((opt) => (
            <option key={opt.value} value={opt.value}>
              {opt.label}
            </option>
          ))}
        </select>
      </label>

      <label className="flex flex-col gap-1 text-sm font-medium text-zinc-700">
        Ounces Consumed
        <input
          type="number"
          step="0.25"
          value={ounces}
          onChange={(e) => setOunces(Number(e.target.value))}
          className="rounded-lg border border-zinc-300 px-3 py-2 outline-none focus:border-indigo-500"
        />
      </label>

      {result && (
        <div
          className={`rounded-xl border p-4 text-sm font-medium ${SEVERITY_STYLES[result.severity]}`}
        >
          <div className="flex justify-between">
            <span>Estimated dose</span>
            <span className="font-mono">{result.doseMgPerKg} mg/kg</span>
          </div>
          {result.requiresEmergencyVet && (
            <p className="mt-2 font-semibold">
              ⚠ This dose falls at or above the 20 mg/kg threshold — contact an
              emergency vet or animal poison control now.
            </p>
          )}
        </div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The result panel deliberately does not say "you're fine" at any severity level below the threshold — it states the number and, only above threshold, adds an explicit action. Under-promising and pointing people toward a professional is the correct default for a tool like this, not a UX nicety.


Why the Server Component Shell Matters Here

The page.tsx this component lives inside isn't just a wrapper — it's doing real work that a client-only SPA version of this tool couldn't do as well:

  • JSON-LD schemas rendered server-side (BreadcrumbList, SoftwareApplication, FAQPage) mean search engines and AI answer engines can parse the page's structure and FAQ content without executing a single line of client JS. For an emergency-use tool, showing up correctly in a zero-click search result is a feature, not just SEO — someone panicking at 1am benefits from getting the answer band directly in the search snippet.
  • The reference content (toxicity chart, emergency protocol, FAQ) ships as static server-rendered HTML, so it's visible and indexable immediately, with the interactive calculator hydrating separately in the sticky sidebar. Neither blocks the other.
  • Zero third-party scripts anywhere in the tree means there's nothing that can shift the calculator's position mid-read the way the pop-under-laden competitor did to me at 2am. The input fields are exactly where they render, every time, which matters enormously more here than on a typical marketing page.

See the Actual Tool

Everything above — the typed theobromine table, the memoized form, the schema-annotated server shell — is live:

HypeCalc Dog Chocolate Toxicity Calculator →

Open the network tab. There's nothing to block, because there's nothing third-party running.


Let's Argue About This

  1. Where's the ethical line for "emergency-adjacent" utility tools? Is a client-side calculator like this even the right medium for something with real health stakes, or should tools like this always force a "this is not a substitute for professional advice" gate before showing a number?
  2. Record<Type, number> lookup tables vs. a switch statement vs. a small class per variant — for a constant table this size, where's the actual complexity threshold where you'd reach for something heavier than a plain object?
  3. How much SEO/schema machinery is "worth it" in a Next.js page component before it starts hurting readability of the component itself? This file mixes JSON-LD generation with JSX render — would you extract that, and where? If you've built a tool where a wrong number has real consequences, I'd genuinely like to hear how you handled the UX around "give the user a clear answer" vs. "don't let them treat a website as a substitute for a professional."

Top comments (0)