A tailor cannot price a hem from the word "hem". The number depends on the fabric, the lining, whether the original stitch has to be kept, and whether the trousers were already shortened once. So the price list has to do something a price list usually does not: publish an answer that is honest only while it stays a range.

That constraint turns out to be a decent little data-modelling exercise, and it is a case where the front-end decision (what to render) and the paper decision (what to print) are the same decision.

The model: an operation with a floor and a list of movers

The naive model is a row with a number. The useful model is a row with a floor, a set of reasons the number can rise, and a flag that says whether a human must confirm before work starts.

const OP = { key: "hem-original-stitch", label: "Trouser hem, original stitch kept", floor: 22, // money, in whole currency units requiresQuote: true, // the floor is not a price movers: ["fabric", "already-altered", "seam-access"], workingDays: 3, }; 

Two fields carry all the honesty here.

floor is not price. Naming it floor in the schema stops the rest of the codebase — the renderer, the JSON-LD, the printed sheet — from quietly treating it as final. The moment a field called price holds 22, somebody writes "only $22" in a headline, and the shop has to explain that at the counter.

requiresQuote is the flag the UI branches on. Where it is false (buttons, a plain hem on cotton), render the number plainly. Where it is true, render from $22 and put the movers underneath in one line. That single boolean is the difference between a price list a shop can stand behind and one it has to apologise for.

Money as a range, not a number

If the value can be either a number or a range, type it once and stop guessing at each call site:

// {from: 22} → "from $22" {from: 22, to: 40} → "from $22" // {exact: 8} → "$8" {onRequest: true} → "quoted at the counter" function money(v) { if (v.onRequest) return "quoted at the counter"; if (v.exact != null) return `$${v.exact}`; return `from $${v.from}`; } 

The renderer never has to decide anything, and the printed PDF and the live page call the same function — which matters more than it sounds, because the two disagreeing is the failure mode customers notice.

The second variable: working days, not days

The other question at the counter is "when?", and it is a second data type with a classic off-by-calendar bug:

// add n working days, skipping the days the shop is closed function promiseDate(from, workingDays, closed = [0]) { // 0 = Sunday const d = new Date(from); let left = workingDays; while (left > 0) { d.setDate(d.getDate() + 1); if (!closed.includes(d.getDay())) left--; } return d; } 

Three things worth noticing:

  • closed is data, not a constant. A shop closed Sunday and Monday is not the same shop as one closed Saturday and Sunday, and "three days" said on a Thursday means different dates in each.
  • The count starts the day after drop-off. Everything else produces a promise one day optimistic, which is the kind of bug that is invisible in dev and expensive in a queue.
  • Format at the edge. Keep the promise as a date object, format it where it is shown. A date formatted at write time is a date that is wrong for every reader in another timezone.

I am not going to pretend a fifteen-line snippet is a scheduling engine. It is the smallest thing that stops the page from printing a promise the shop cannot keep.

Paper as a pointer, not a snapshot

Here is the part that generalises past tailoring, and it is the one I would keep if I could keep only one thing:

A printed object should contain a pointer, never the data it points at.

A QR code is a string. If the string is a PDF of the price list, then the printed card is a snapshot: the day a price changes, the card on the counter is wrong, and the only fix is to print it again. If the string is the page's address, the card is a pointer: the content behind it can change forever and the card stays true.

// wrong: the code dies with the data qr.encode(`${host}/menus/price-list-2026-11.pdf`); // right: the code outlives every revision qr.encode(`${host}/s/acme-tailoring`); 

This is the same reasoning as "don't put a database row in a cache key you can't invalidate". A snapshot with no invalidation path is a bug with a print run attached to it.

Where the geometry bites back

The code has to be physically readable, and that is a number, not a vibe. In the PDFs our kit generates (error correction M, four-module quiet zone, 33 modules across):

Piece Sheet Code Reads at
Counter card 93.0 × 127.5 mm 48 mm ~0.5 m
Round sticker 70 mm 40 mm ~0.4 m
A4 poster 210 × 297 mm 126 mm ~1.3 m

The 1:10 code-size-to-distance rule is an industry rule of thumb, not part of ISO/IEC 18004, and it assumes a matte, lit, flat surface. Behind glass, under gloss laminate, or through a bowed plastic sleeve, plan for 1:8.

The engineering lesson: the printing step is the one place your content is frozen, so it is the only place a stale-data bug becomes permanent. Everything else can be redeployed.

What this deliberately does not do

  • No customer records. The page holds prices, hours and clauses, so it needs no names, no phone numbers and no privacy policy worth the name.
  • No till, no tickets, no SMS. Those are a different product with a monthly price.
  • No exact prices where the garment decides. A page that promises a number it cannot keep is worse than no page.

If you want the long version of the reasoning — the row set, the six things that move an alteration price, the rush rule written once, the scope lines on the cleaning table — it is here: an alteration and tailoring price list on one page.

The $49 version of the printed kit (live page plus eight files, one payment, no subscription) is here, and the three-year cost calculator is what I use to argue with a monthly-fee quote.

I build MapToSite, which sells this page and the printed kit to local shops. The prices above are its published ones.