I wanted a tool that shamed runaway meetings without exposing anyone’s salary spreadsheet. BurnRate became that: a single-page app that keeps every byte on-device, shows a live HH:MM:SS and cost meter, and hands you a copy-ready recap the moment you tap “Stop”. You can try it at https://burnrate.ainsworth.dev.
The product in three stages
- The intake form, where you pick participants, annual or hourly salary, and currency. It’s a tall card with plus/minus “pill” buttons sized for thumbs, number inputs that keep tabular digits, and a sliding currency selector showing the top five symbols.
- The live meter, which takes over the moment the form submits. It shows elapsed time, total spend split into major and minor digits, and cost per minute, with Pause/Resume and “Stop & Recap” buttons either side.
- The summary. Stopping locks the stats, shows a privacy reminder (“all calculations stayed locally”), and gives you the shareable text block, Copy and native share buttons, and “Start new session”, which wipes state immediately.
All three states exist inside src/app/page.tsx, so there are zero route changes. That makes it feel like a dedicated tool rather than a website you click through.
Local-only math with Zustand stores
Participants, salary, and timer state never leave the browser. Two Zustand stores run the whole thing:
useSessionStoretracks the current session payload and aresetCounterso the intake form can remount fresh when you nuke a session.useTimerStoretracks start time, pause offsets, elapsed seconds, total cost, and a final snapshot for the recap.
The formula that powers everything lives in src/lib/session.ts:
const WORK_HOURS_PER_YEAR = 2080;
const SECONDS_PER_HOUR = 3600;
export function buildSessionSalaryPerSecond(participants: number, inputSalary: number, unit: SalaryUnit) {
const hourly = unit === "annual" ? inputSalary / WORK_HOURS_PER_YEAR : inputSalary;
return (hourly / SECONDS_PER_HOUR) * participants;
}
When you submit the form, I compute salary-per-second once, push it into the timer store, and throw everything else away until the summary stage. Hitting “Start new session” calls resetTimer() + clearSession() which immediately wipes both stores and rerenders the intake form with your locale currency default.
A drift-free 30 fps timer
I didn’t want a timer that drifts by seconds over a long meeting. useCostTicker wraps requestAnimationFrame and only recomputes when we’ve crossed a 33 ms frame, using performance.now() so it stays accurate even if the tab was backgrounded. The store exposes pure math helpers, so the ticker hook stays tiny:
export function useCostTicker() {
const status = useTimerStore((state) => state.status);
const startTime = useTimerStore((state) => state.startTime);
const updateMetrics = useTimerStore((state) => state.updateMetrics);
useEffect(() => {
if (status !== "running" || startTime === null) return;
let frameId: number;
let lastUpdate = 0;
const tick = () => {
const now = performance.now();
if (now - lastUpdate >= FRAME_INTERVAL_MS) {
const elapsedSeconds = calculateElapsedSeconds(
now,
startTime,
useTimerStore.getState().pausedOffset,
);
updateMetrics(elapsedSeconds);
lastUpdate = now;
}
frameId = requestAnimationFrame(tick);
};
frameId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameId);
}, [status, startTime, updateMetrics]);
}
Because the updates stay pure, the UI never jitters. Both the 5 to 6 digit total and the HH:MM:SS readout stay clean tabular numbers at around 30 fps.
Inputs designed for real meetings
- Participants and salary use chunky accent buttons in distinct hues, so adding a contractor mid-call feels tactile even on a phone. The participants input clamps at 1, and salary clamps at 0 and switches steps (
$5kannual,$5hourly). - Annual against hourly is a segmented control that also resets the input to sane defaults, $50k or $65, when you toggle it.
- The currency slider shows the top five symbols and moves a rounded highlight with CSS transforms, which is faster to use than a select input. It plugs into
useLocaleCurrency, which sniffsnavigator.languageand preselects USD, EUR, INR and the rest from a dead-simple region map.
Everything lives inside Tailwind 4 utility classes, plus a glass-slate helper defined in globals.css that keeps the cards consistent in light and dark modes.
Recaps, copy, and native share
Once you stop the timer, BurnRate builds a sentence like:
Meeting burned $1,820.00 in 00:37:45 with 8 attendees ($227.50 each). Track your next meeting with BurnRate: https://burnrate.ainsworth.dev
- A
useMemobuilds the string from the final snapshot, so it never re-renders unnecessarily. - If
navigator.shareexists you get both Copy and Share buttons, and if it doesn’t you only get Copy. - The share payload always includes the per-attendee math and the site URL, so it still makes sense pasted into Slack or an email.
Because the data never leaves memory, tapping “Start new session” wipes everything on the spot without a modal or a confirm dialog. That’s the whole point of the app.
Visual and metadata polish
The layout leans on two Google Fonts through the App Router (Space Grotesk and Geist Mono), gradient backdrops, and “glass” cards. A /og route renders a custom image on the Edge runtime with callouts for privacy, accuracy, and frame rate, and lib/site-metadata.ts supplies the structured data and Open Graph tags, so a shared link previews as the actual product.
Guardrails I rely on
npm run test:unit(Vitest) covers the tricky math so I don’t guess. There are fixtures for annual ↔ hourly conversions, per-attendee breakdowns, and timer math (pause offsets, frame snapping, HH:MM:SS formatting). If a refactor changes the WORK_HOURS_PER_YEAR constant or breaks the paused offset math, tests fail instantly.npm run test:e2e(Playwright) replays the actual user journey: submit the form, watch the cost climb, pause/resume, stop, copy the recap, and confirm “Start new session” wipes both stores. I run it before publishing so I know the single-page flow still feels instant.npm run lintleans on Next.js’s ESLint preset, catching hooks misuse, accessibility mistakes, and stray unused exports long before deployment.
Takeaways
- A live cost meter turns “can we wrap this up?” into dollars per second, and that’s a much easier argument to win.
- Never persisting anything, and saying so in the UI, is what makes people willing to type in real salaries.
- The peripheral things (the moving currency slider, the reset-aware form key, the OG card) aren’t required, but they’re what makes people trust it enough to open mid-meeting.
If you run a meeting that should have been an email, BurnRate will let everyone see what that indecision costs in real time. Give it a spin at https://burnrate.ainsworth.dev.