The scale is a noisy instrument. Water, salt, a late dinner or a long run can move it a kilogram from one morning to the next, so a single reading says very little, and a week of them says a lot. The apps I tried got that part right. What I didn’t like is where they put the readings: on someone else’s server, next to an account, a profile and, sooner or later, a privacy policy update.

Weigh to Go is the diary I wanted instead. You log a weigh-in a day and it shows where you’re heading: the trend under the noise, projections, weekly averages, the stretches where the scale climbs back up, and more, in English and Portuguese. It runs entirely in the browser, and the weigh-ins never leave the device. It’s a diary, not medical advice. This is how it works inside, and how I check that it keeps its promise. The code is on GitHub.

Why nothing leaves the device

Body weight is health data. It doesn’t need to be anywhere but with the person who stepped on the scale, and a diary has no reason to send it anywhere: every calculation it does fits in a browser tab. So there is no account, no analytics and no request to another site; GitHub Pages only serves the files.

A promise in a README is easy to break by accident, so the page enforces it. Its Content Security Policy is:

default-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none';
form-action 'none'; require-trusted-types-for 'script'; trusted-types sw-url

connect-src 'none' blocks the APIs a script normally uses to send data: fetch, XHR, WebSockets, EventSource and beacons. Nothing loads from other origins, forms can’t submit anywhere, and Trusted Types turn any string written into the DOM as HTML into an error. One Trusted Types policy is allowed, sw-url, and all it does is accept the service worker’s own URL, /sw.js, and throw for anything else.

A CSP can’t stop a navigation carrying data in its URL, so the promise also rests on the code, but the easy mistakes are impossible. GitHub Pages can’t send headers, so the policy lives in a <meta> tag; and since a policy there can’t forbid framing, the page stays hidden until a small script has checked that it isn’t framed.

Where the data lives

Weigh-ins and the profile (a goal and a height) live in IndexedDB; screen preferences, such as which chart layers are on, in localStorage. Weights are stored in kilograms and unrounded: a reading in pounds is saved as lb × 0.45359237, so converting it back shows exactly what was typed.

Every write is checked first and then runs as one transaction, so a backup that fails halfway can’t leave half a diary behind. Browsers don’t always cooperate, and the store is built for that:

  • No IndexedDB, or storage blocked. The store falls back to memory, so the page still works for the visit, and it says plainly that nothing will be kept. It also records why: when the database on disk is newer than the page (an old tab still open) or failed to open, the data may well be there, so the page asks for a reload instead of claiming nothing is saved.
  • Eviction. A browser may clear a site’s storage under pressure, and Safari does after seven days of using Safari without visiting the site, unless it’s added to the Home Screen. After the first save (never on page load, since Firefox may show a prompt) the diary asks for persistent storage. Private windows, emptied when they close, look like normal ones to the page. That is what backups are for.

Backups that don’t undo corrections

The backup is a JSON file. It moves the data between devices, and between Safari and the installed app on an iPhone or iPad, which keeps its own storage apart from Safari’s. Here it is with made-up values:

{
  "app": "weigh-to-go",
  "format": 1,
  "exportedAt": "2026-10-19T08:00:00.000Z",
  "weighIns": [{ "date": "2026-10-18", "kg": 77.7, "savedAt": "2026-10-18T07:12:00.000Z" }],
  "profile": { "goalKg": 70, "heightCm": 170 }
}

Importing one either replaces everything or merges it with what is there. For a day both sides have, a merge keeps the weigh-in saved later (the file’s on a tie), so importing last month’s backup can add days but can’t put back a typo you corrected yesterday. It can bring back a day you deleted, since a deletion leaves nothing to compare. The import then says what it did, by day.

Every row is validated first: a bad date, a weight outside 20 to 500 kg, or a day before 1900 or more than a day ahead is skipped and counted. The diary reminds you to export once you have seven weigh-ins and no backup, and again 30 days after the last one. On an iPhone or iPad, export a backup in Safari and import it in the installed app; the app suggests this while its diary is empty.

A paste box also takes a history from a spreadsheet, and when a date like 05/10 could be read either way and nothing in the paste settles it, the page shows how it read the date and saves only after you say yes.

Seeing through the daily noise

The analysis is pure TypeScript functions over the weigh-ins, tested against hand-checked series. The 7-day average is the plain mean of the last seven days, shown only when they hold at least 4 weigh-ins, so a week with two readings doesn’t pretend to be an average.

The smoothed trend is an exponential moving average: each weigh-in moves it 10% of the way.

trend₀ = w₀
trendₙ = trendₙ₋₁ + 0.1 × (wₙ − trendₙ₋₁)

A one-kilogram spike moves it by 100 grams, and the effect fades by 10% with every weigh-in after it. The price of that calm is lag: on a steady loss, the trend runs behind the scale by about nine weigh-ins’ worth of the slope ((1 − α) / α with α = 0.1). That’s why the dashboard shows both lines and the daily dots.

Advertisement

Projections, with honest error bands

The chart can project the weight by three methods:

  • Whole period: a least-squares line through every weigh-in.
  • Last 3 weeks: the same over the last 21 days, when they hold at least 5 weigh-ins. With fewer it would only repeat the whole period, and the dashboard says so rather than calling it recent.
  • Smoothed trend: the slope of the trend from the weigh-in at least 14 days before the last one, continued from the trend’s latest value.

For the two fitted lines, the chart draws a 95% prediction band around the projection, h days after the last weigh-in:

ŷ(h) ± t₀.₉₇₅(n − 2) · s · √(1 + 1/n + (h − x̄)² / Sxx)

where s is the residual standard error of the fit, x̄ the mean day of the data and Sxx the sum of squared day deviations. The band widens as h moves away from the data, as it should. The t is Student’s, not the normal 1.96: with 3 weigh-ins the quantile is 12.7, about 6.5 times wider, which is the honest answer when you’ve weighed yourself three times.

The projection’s caption ends with “It extends the past pace; it is not a promise.” The same models date the goal and milestones every 5% of the starting weight. When a pace is flat or rising, the answer is “never at this pace”; when there are too few weigh-ins for a figure, the page says what it needs.

The weekly pace is shown against 0.5% to 1% of the current weight a week, a range often cited for adults losing weight: a rough reference, not a target.

Hiccups, rates and patterns

The most discouraging part of a diet is the stretch where the scale climbs back up. The diary calls those hiccups and finds them with explicit rules: a climb from a low point to a peak that lasts at least 3 days and adds at least 0.5 kg, tolerating dips of up to 0.5 kg below the highest value so far, as long as the weight doesn’t fall back below where the climb started. A gap of more than 7 days between weigh-ins ends a climb: no data, no hiccup. The rules can be changed. For each hiccup it reports the gain, its length, and how many days after the peak the weight got back to where the climb began, which is usually the reassuring part.

The compound daily rate treats loss like compound interest in reverse: a constant percentage of the day before. Because a constant percentage loses fewer kilograms as the weight drops, a projection at that rate drifts away from the linear one over time.

Then the smaller views: weekly averages, the change by weekday, a calendar, BMI with the WHO adult bands (noted as a rough, population-level measure), and records.

Charts without a chart library

The charts are SVG built by hand from DOM nodes, using only d3’s math modules (d3-scale, d3-shape, d3-array, d3-time), so nothing is ever written as an HTML string and Trusted Types have nothing to object to. The pointer, a finger and the keyboard all move the same cursor, a hidden live line reads the day out to a screen reader, weigh-in dots are drawn as one path per class, and showing a section draws only that section (redrawing all of them took about 300 ms with 3,000 weigh-ins).

Two languages, with real plural rules

Every string goes through a dictionary, with the Portuguese typed against the English, so a missing translation fails the type check. Numbers and dates go through Intl in the reader’s locale.

Plurals are where hand-written n === 1 ? 'day' : 'days' goes wrong. The diary uses Intl.PluralRules, told how many decimals the number is shown with:

rules = new Intl.PluralRules(tag, { minimumFractionDigits: digits, maximumFractionDigits: digits });
return rules.select(n) === 'one' ? forms.one : forms.other;

That gets the cases a simple check misses. In English, “1.0 points” is plural, because the decimals count. A BMI that fell by 1.9 is “1,9 ponto” in Brazilian Portuguese, where the whole part decides, but “1,9 pontos” in Portugal, which is why the rules are chosen by locale, not by language.

Installable and offline

The diary is a progressive web app: a manifest per language (so a Portuguese install opens in Portuguese), icons including a maskable one, and a service worker that a small Vite plugin writes after the build, so it knows the file names.

The worker does one thing: same-origin requests, answered from its cache or the network. Pages come from the network first, so a deploy shows at once; offline, or after about 3 seconds without an answer, the page cached with that version shows instead. Everything else comes from the cache first, which is safe because each version has its own cache, and old caches are deleted on activate. A new version waits for you to press Reload on the notice; once one tab switches, the others show the notice too, and their Reload simply reloads. The page’s policy doesn’t cover the worker, so a test checks that it answers only the site’s own requests.

How quality is enforced

  • Types and unit tests. TypeScript strict, ESLint’s strictTypeChecked rules, and more than 300 Vitest tests: the analysis, the store against fake-indexeddb (merges, failed transactions), the parsers, and the dashboard’s DOM in happy-dom.
  • The built site in a browser. Playwright runs end-to-end tests in Chromium (logging, an ambiguous paste, backup and restore, language and units, the chart’s keys, offline use) and axe on every page in light and dark, desktop and phone. Lighthouse CI fails the build below 100 in accessibility or 95 in best practices and SEO, and warns when the median of three runs scores below 90 in performance.
  • The built files. A check confirms that the policy is in every page, right after the charset, with no inline scripts, styles or data: URIs.

All of it runs on every pull request, and the site is deployed only when it passes. Dependabot proposes dependency updates only a week after their release, and the project’s rule is to install new dependencies with the same wait, as a buffer against a compromised version.

Try it

Weigh to Go is at weightogo.jonathas.net (in Portuguese at /pt/), and the code is on GitHub. Log a weigh-in a day for a couple of weeks and look at the trend, not the scale. Export a backup now and then. Anything about your health belongs with a doctor; the diary only describes your own readings.