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
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)
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,
};
}
(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 newChocolateTypewithout 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. -
requiresEmergencyVetcomputed 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>
);
}
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
- 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?
-
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? - 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)