DEV Community

Cover image for The URL is the save file: a time card calculator with no backend and no accounts
Miles Ford
Miles Ford

Posted on

The URL is the save file: a time card calculator with no backend and no accounts

Most time card calculators online fall into two groups. The old ones forget everything when you close the tab. The newer ones want an email address before they'll save a week of hours for you.

For HourTotal I wanted a week of hours that survives a closed tab and can be sent to a manager, without anyone signing up for anything. So the whole time card lives in the URL.

What goes in the link

A time card is small. It's five workdays with up to two punch pairs each, plus a name, a pay period, an overtime rule and an hourly rate. As JSON that's about 390 characters for one week and 650 for two.

After every edit, the calculator serializes that state and writes it to the fragment:

function saveHash() {
  const s = settings();
  const json = JSON.stringify({
    s,
    e: entries.map((wk) => wk.map((e) => [e.in1, e.out1, e.in2, e.out2, e.breakMin])),
  });
  const b64 = btoa(unescape(encodeURIComponent(json)))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  history.replaceState(null, "", location.pathname + location.search + "#" + b64);
}
Enter fullscreen mode Exit fullscreen mode

Loading runs the same steps in reverse. The calculator never touches localStorage and never sends the data anywhere, so the link is the only place it exists.

Each row is an array (["7:05 AM","12:10 PM","1:00 PM","3:35 PM",0]) rather than an object. Repeating in1, out1 and the rest on every row made the JSON about 40 percent bigger, and the code fixes the order anyway.

The encoding is base64url, not plain base64. Plain base64 uses +, which some URL decoders read as a space, and ends in = padding that adds nothing. Swapping + and / for - and _ and dropping the padding fixes both.

btoa only accepts Latin-1, and the site has Spanish pages where a name like Muñoz is ordinary. Running the string through encodeURIComponent and then unescape turns it into UTF-8 bytes that btoa will take. It's an old trick. If you'd rather avoid deprecated functions, TextEncoder does the same job with a few more lines.

The URL follows every edit, so it has to be replaceState. With pushState, the back button would walk you through each digit you typed. It also can't run on every keystroke: Safari throws a SecurityError after 100 replaceState calls in 30 seconds, which a fast typist filling in a week can reach. The first version did exactly that. Now typing queues a single write 500 ms after the last edit, and the Share button and pagehide flush it right away.

An empty card writes no hash at all. A fresh page has a clean address, and nobody copies a wall of base64 by accident.

If someone pastes half a link, decoding throws. The loader catches that, logs a warning, and opens a blank calculator instead of a broken page.

Why the fragment and not a query string

The part after # never leaves the browser. Browsers don't include it in the HTTP request, so it doesn't show up in server logs, CDN logs or Referer headers. When Slack unfurls a pasted link, its bot requests the page without the fragment, so the hours never reach Slack's servers either.

The site is static files on Cloudflare Pages. Every visitor gets the same HTML. The hours, names and rates exist in the visitor's browser and in any links they decide to send, and that's it.

What you get without building it

Saving a card means bookmarking it. Sharing means sending the link: the Share button opens the system share sheet through navigator.share and falls back to copying the URL where that isn't available. A worker can text a week to a supervisor, who opens the same grid with the same overtime split and gross pay. Old links still open old weeks, which covers history. A small service worker caches the page, and since nothing needs a server round trip, the calculator works the same with no signal.

The trade-offs

Anyone who has the link can read the card. That's the design, but it means a link with your name and rate shouldn't go anywhere public. A password would mean encrypting the payload in the browser with the Web Crypto API and asking for the password on open. I left that out: most people send their week to exactly one person, and a forgotten password would lose the card for good.

Links get long. A two-week card with a name and a pay period is around 870 characters. Browsers don't care, but it's an ugly thing to paste into a text message.

There's no sync between devices. Edit the card on your phone and the bookmark on your laptop still has last night's version, so you send yourself the new link. For a weekly time card that has been fine. For anything several people edit together, it wouldn't be.

The URL format is now a public schema. Old links have to keep opening after every change, so the loader gives every field a default (s.round || 0, s.ot || "weekly40") and never assumes a field is present.

The math is open source

The code that turns punches into hours and overtime is one dependency-free module: hourtotal-calc on GitHub. It parses what people type (8, 830, 5p, 1730), handles overnight shifts, and implements California's 8 and 12 hour daily limits and the seventh-day rule, without counting daily overtime a second time toward the weekly 40.

The calculator is at hourtotal.com. Fill in a week, hit Share, and open the link in a different browser to see it come back. If you run payroll, I'd like to know what it's still missing.

Top comments (1)

Collapse
 
launchgatecheck profile image
Launch Gate •

Using the fragment so the hours never reach Slack's unfurl bot is a nice detail. Most people only find that out after a leak.

Since the URL is now a public schema, one cheap thing I'd add: a version field (v: 1). Defaults like s.ot || "weekly40" cover fields you add, but not the day you want to rename one or change what a value means. Changing the row array to a different order, for example, would silently mis-read old links. With a version you can branch the loader and keep old bookmarks exact.

If link length bugs you, CompressionStream('deflate-raw') plus base64url usually cuts that kind of repetitive JSON by 40-60%, and it's built into current browsers. The trade is an async save and a slightly harder-to-debug hash.