# /data/price-book.json — the price book builder's one data file (version 1)

Written by every build (`npm run marketing`, scripts/marketing/priceBook.mjs) from:
the six English price list templates' own sample rows (the jobs; the rows that are rates or terms, not jobs, are listed under
`notJobs` and never priced), `scripts/marketing/data/oews.json` (BLS OEWS May 2025 wages — national, state and metro area; refreshed
by `scripts/marketing/fetchOews.mjs` from bls.gov only), `scripts/marketing/data/ecec.json` (BLS Employer Costs for Employee
Compensation, Table 4, private industry by occupational group; refreshed by `scripts/marketing/fetchEcec.mjs` from bls.gov only) and
`scripts/marketing/data/price-book-parts.json` (the parts' retail prices,
checked by hand at major retailers' public product pages, each with its link and date; and the IRS business mileage rate). Never edit
the JSON by hand: edit the data and rebuild. No AI made any of it.

**What the numbers are, and are not.** The wages are what EMPLOYEES earn (BLS); the employer costs are what employers pay on top of
wages for insurance, retirement and legally required benefits (BLS ECEC); the parts are retail shelf prices of parts on the
date shown; the job hours are Workeey's starting guesses, labelled "a typical estimate to start from, set your own" — never data and
never sourced to anyone. Nothing in this file is what anyone charges, an average price or a market rate; a reader of it must not
present it as one.

**Answering trade + place → price book, with no transform:** `import { bookOf, csvOf, suggestedRate, wageOf } from '/site-pricebook.js'`
(public/site-pricebook.js runs in Node and in the browser; it imports /site-calc.js and /site-money.js beside it).

`bookOf(data, { trade, state?, metro?, rate?, markup?, travel?: { on, miles, minutes }, jobs?: { <job id>: { hours?, parts? } } })` returns

```
{ sel: { trade, state, metro },
  trade: { id, name, soc, occupation, template, page },
  place: { state, metro, label, level: 'metro' | 'state' | 'national', fallback },   // fallback: "No BLS figure for X; showing Y." or ''
  wage: { level, name, median, annualMedian, markers, reference },
  onTop: { group, share },                // the ECEC occupational group and its on-top share (0.3136 = 31.36% of wages)
  profit,                                 // annualMedian × (1 + onTop.share): what the calculator treats as profit before tax
  rate: { value, suggested, own },        // own = the caller's rate differs from the suggested one by half a cent or more
  why: [sentence…],                       // the suggested rate's reasoning, in plain steps (the last: "So this starts from what the
                                          // same work costs an employer, not from what an employee takes home.")
  calc: { pay, costs, tax, weeks, hours, billable },   // what the hourly rate calculator was fed
  markup, travel: { on, miles, minutes, irsRate, irsYear, mileCost, timeCost, cost },
  jobs: [{ id, name, basis, includes, hours, startHours, partsLow, partsHigh, partsMid, partsStart, partsCost, partsPrice,
           labor, travel, price, clientSupplies, partsPending, partsNote, unchecked: [part…], parts: [Part…] }],
  hoursLabel, checked, honesty,           // honesty = honestyOf(data, trade): that trade's own line
  parts: { total, checked, unchecked, pending, date, none },   // partsState(data, trade): what was actually checked
  sources: [{ name, url, checked }] }     // BLS wages, BLS employer costs, the IRS rate, then every product page once
```

Also exported: `honestyOf(data, tradeId?)` (the honesty line; all trades when no trade), `partsState(data, tradeId?)`,
`partsWords(data, tradeId?)`, `partsRange(job)`, `priceJob(job, { rate, markup, travelCost }, own)`, `csvOf(book)` (a title row, then
`CSV_HEAD`, then one row per job; no credit row), and the PDF's `PDF_TITLE` ("Price book"), `pdfRows(book)` and `pdfCaption(book)`
(the trade and place go in the caption). The PDF is drawn by `/site-maker/price-book-pdf.js` (`PDF_MODULE`), loaded only on Download.

```
```

THE MATH, per job: `labor = hours × rate`; `partsPrice = partsCost × (1 + markup / 100)`; `price = labor + partsPrice + travel`, each
rounded to the cent. THE PARTS RANGE: each checked part counts `qty` times (its `low`/`high` × `qty`); parts that share an `alt` are
one choice, so the group runs from its lowest low to its highest high; the groups and the lone parts add up to `partsLow`/`partsHigh`.
`partsCost` starts at the middle of that range (`(partsLow + partsHigh) / 2`), or 0 when the job's
own wording has the client supply the part (`clientSupplies`) or nothing was checked. `travel` (when `travel.on`) =
`miles × irsMileage.rate + minutes / 60 × rate`, added to every job. A caller's number that is not finite and non-negative is ignored.

THE SUGGESTED RATE is the hourly rate calculator's own math (`hourlyMath` in /site-calc.js) with its defaults for costs, taxes, weeks,
hours and the billable share. It starts from the BLS median YEARLY wage — the metro area's when `metro` is given and BLS published one,
else the state's, else the national figure (`place.fallback` says each step down) — and adds what employers pay on top for the
trade's ECEC group: `share = (insurance + retirement + legallyRequired) / wages` (paid leave and supplemental pay left out, since the
calculator's weeks and hours already cover time off). `profit = annualMedian × (1 + share)`; the calculator's pay (take-home) is
`profit × (1 − tax / 100)`. So the rate starts from what the same work costs an employer, not from what an employee takes home.

## Top level

| key | what |
|---|---|
| `version` | 1 — bumped when a field changes meaning or is removed |
| `about` | one line on what the file is |
| `reference`, `released` | the BLS data period ("May 2025") and its release date |
| `checked` | `{ wages, employerCosts, parts, mileage }` — ISO date each data set was last read or checked (`parts`, `mileage` null until checked) |
| `honesty` | the line the tool and every trade page show near the top |
| `hoursLabel` | the words every job's hours carry |
| `defaults` | `{ markup, travel: { on, miles, minutes } }` — starting points, said as such on the page |
| `sources` | `{ bls: { name, url, files }, parts: { method } \| null }` |
| `ecec` | `{ source, release, reference, released, table, url, checked, groups: { nrcm \| service: { name, wages, insurance, retirement, legallyRequired, paidLeave, supplemental, total, line } } }` — dollars per hour worked, as BLS published them; `line` is the table row quoted |
| `irsMileage` | `{ rate (dollars a mile), year, effective, url, quote, also?: { url, name, quote } }` from irs.gov (`also`: the IRS announcement that set the rate, a second irs.gov source), or null until checked |
| `states` | `{ XX: { name, metros: [metro code…] } }` — the 50 states and DC; a metro spanning states is listed under each |
| `metros` | `{ <BLS area code>: { name, states: [XX…], wages: { <SOC>: Wage } } }` — BLS metropolitan statistical areas; an occupation BLS published nothing for in that area is absent |
| `trades` | `[Trade]`, below |

## Trade

`{ id, name, one, soc, occupation, ececGroup, template: { name, url }, page, wages: { national: Wage, states: { XX: Wage } }, notJobs: [{ name, why }], jobs: [Job] }`

One BLS occupation per trade: plumbing 47-2152, electrical 47-2111, hvac 49-9021, handyman 49-9071, cleaning 37-2012,
landscaping 37-3011. ECEC group: `nrcm` (natural resources, construction and maintenance) for the 47- and 49- trades, `service` for
cleaning and landscaping.

**Wage** — `{ median, annualMedian, p10, p90, markers }`, hourly figures in dollars, yearly whole dollars, exactly as BLS published
them. A figure BLS did not publish is `null` and `markers[field]` keeps BLS's mark: `*` no wage estimate, `**` no employment
estimate, `#` at or above the top code.

## Job

`{ id, name, basis, includes, hours, clientSupplies, parts: [Part], noParts?, partsPending?, partsNote? }` — `name` and `includes`
are the template row's own words; `basis` is the unit the price is for (`flat`, `per window`, `per 100 sq ft`…; a template priced by
the square or linear foot is priced per 100 of them); `hours` is OUR starting guess for one `basis`. `partsPending` = the parts research
has not landed yet (printed "not checked yet"); `partsNote` is the research's note on the job's parts, shown under them.

## Part

Checked: `{ part, why, note?, rangeUnit?, alt?, low, high, qty, qtyNote?, per?: [{ low, high, unit }], products: [{ retailer, name, url, price, pricePerUnit?, unit,
priceText, sale?, where, checked }] }` — `low`/`high` are the lowest and highest product price seen, per `rangeUnit` when the products
are priced by the unit (`pricePerUnit`; the build refuses a range its products do not show); `qty` is how many of that one job takes
and `qtyNote` how it was worked out (shown to the reader, e.g. "a cubic yard is 27 cu ft = 13.5 two-cubic-foot bags"); `alt` names a
group of parts that are one choice ("one of these"); `sale` is a sale price seen on the day, kept and never used; `per` gives the research's other ranges (bagged mulch per cubic yard, fertilizer per 1,000 sq ft of the coverage printed on each bag), each re-worked by the build from the products, which refuses a mismatch. `where` says where the price is from: a Menards or Ace Hardware price is ONE STORE's online price ("store set to <town>"), never a national price — the page prints "one store's online price (<town>)" beside it (`whereOf`), and the honesty line says so (`storeWords`). Prices are the regular shelf price; Menards' mail-in rebate is ignored. Not checked: `{ part, why, alt?, unchecked: true, reason, products: [] }` — printed "not checked", never priced.
