# Home Base Search — job-data integration guide

(Live copy: https://careerops-jobdata.pages.dev/HOME_BASE.md)

For the Home Base Claude Code session. Home Base keeps its own map and UI. It
only **fetches static JSON over HTTPS**: no clone of Career Ops, no shared
database, no build step, and no GitHub Actions. Career Ops refreshes the data
every morning (about 05:30 Pacific) and uploads only the files that changed.

## Base URL

```
https://careerops-jobdata.pages.dev/v1/
```

This is a Cloudflare Pages project (free tier). Each deploy is immutable, and the
live URL switches atomically, so a reader never sees a half-updated export.
CORS is `*`. `/v1/*` responses are cached for 15 minutes. Daily snapshots are
immutable.

## Files: small summaries first, listings on demand

| URL (relative to `/v1/`) | Size | Use |
|---|---|---|
| `manifest.json` | ~10 KB | **Read first.** Version, `generated_at`, counts, freshness, coverage, rights |
| `areas/states.json` | ~18 KB | One row per state |
| `areas/metros.json` | ~170 KB | One row per CBSA (metro/micro) |
| `areas/counties.json` | ~340 KB | One row per county |
| `areas/cities.json` | ~1.3 MB | One row per Census place, with lat/lon |
| `areas/remote_us.json` | <1 KB | US-remote totals and pay |
| `jobs/by-cbsa/{cbsa}.json` | varies | Listings for one metro, when the user opens it |
| `jobs/by-state/{ST}.json` | up to ~7.5 MB (CA) | Listings for one state (USPS code, e.g. `CA`) |
| `jobs/remote.json` | ~7 MB | US-remote listings |
| `jobs/unlocated.json` | ~2 MB | On-site listings with no resolvable US state (rarely needed) |
| `schema.json` | | JSON Schema for one listing row |
| `snapshots/YYYY-MM-DD/areas/*.json`, `snapshots/YYYY-MM-DD/manifest.json` | | Last 30 days of summaries, for trends |

A missing `jobs/by-cbsa/{cbsa}.json` (404) means **zero listings**, not an error.

## Join keys

| Level | Area row key | On each listing's `locations[]` |
|---|---|---|
| State | `state` (USPS), `state_fips` | `state`, `state_fips` |
| Metro | `cbsa` (OMB 2023 CBSA code) | `cbsa`, `cbsa_title` |
| County | `county_fips` (5 digits) | `county_fips`, or `null`. See below |
| City | `place_geoid` (7-digit Census place) | `place_geoid`, `lat`, `lon` |

## Area row (compact)

```json
{ "cbsa": "35620", "title": "New York-Newark-Jersey City, NY-NJ",
  "open_postings": 1334, "local_postings": 1172, "local_employers": 608,
  "remote_postings_listing_area": 162, "listed_postings": 860,
  "pay_median": { "base": {"n":185,"median":142500}, "ote": {"n":149,"median":205000},
                  "total": {"n":13,"median":100000}, "unstated": {"n":175,"median":157500} },
  "pay_coverage": 0.391, "low_sample": false }
```

- `local_postings` counts on-site, hybrid and unspecified postings. Remote postings
  that name the area are counted separately in `remote_postings_listing_area`.
  Use `local_*` for "jobs near this home".
- `listed_postings` is how many rows the `jobs/` files hold for the area. It can
  be **lower** than `open_postings`, because some hiring systems are cleared only
  for counts (see Rights). Show "1,334 postings · 860 viewable", not one number.
- `low_sample: true` means fewer than 10 local postings. Show the pay median as
  indicative, or hide it.

## Listing row (excerpt; full list in `schema.json`)

```json
{ "title": "Account Executive", "company": "Acme", "url": "https://job-boards.greenhouse.io/acme/jobs/123",
  "locations": [{ "city": "Austin", "state": "TX", "county_fips": "48453", "cbsa": "12420", "place_geoid": "4805000", "lat": 30.3, "lon": -97.75, "county_candidates": null }],
  "remote": false, "pay_min": 90000, "pay_max": 110000, "pay_period": "year",
  "pay_basis": "base", "pay_basis_wording": "base salary",
  "posted_at": "2026-10-14", "last_seen": "2026-10-23", "location_quality": "county",
  "source_system": "greenhouse", "public_use": "redistributable" }
```

**Since v1.3**, each listing also has these fields, all derived from the title:
- `department` (Career Explorer department) and `family`; the labels are in
  `manifest.families`;
- `seniority`: entry | mid | senior | manager | director | executive;
- `track`: technical | non_technical;
- `commission_risk` (boolean);
- `industry_hint`: `education` or null;
- `posted_age_days`.

`us_eligibility` is `us` or `us_inferred`. `us_inferred` means the posting names no
place, but its employer's board is essentially all-US.

**Since v1.4:**
- Each listing has `location_scope`:
  - `single`: physically based in one place;
  - `multi`: one of 2–5 listed places;
  - `territory`: covers 6+ places, or a territory/region role;
  - `none`.
- Each area row has `local_based_postings`: local postings based only in that
  area. A territory job that also lists Bakersfield counts in `local_postings`
  but not here.
- Optional to use. Nothing existing changed meaning.

## Rules to keep

1. **Pay.** Always show `pay_basis` next to the figure: base, OTE (on-target
   earnings), total comp, or "basis not stated". Never average different bases
   together. Figures are employer-advertised and annual only; hourly pay is never
   converted and nothing is estimated.
2. **Multi-location.** A posting appears in **every** area it lists, so don't sum
   areas. Use `manifest.counts.jobs` for a national total.
3. **Remote stays separate.** Use `jobs/remote.json` and `areas/remote_us.json`.
   Don't place remote jobs on the local map.
4. **Unverified county.** `location_quality` tells you how precise a listing's
   location is:
   - `county`: placed in a county.
   - `metro`: the city spans several counties (New York City, for example). Here
     `county_fips` is `null` and `county_candidates` lists the possible FIPS
     codes; don't assign the job to one county.
   - `state_only`: the city couldn't be resolved.
   - `remote_no_location`: a remote job with no place named.
   - `unresolved`: US-eligible, but no place found.
5. **Incomplete coverage.** Counts are what this engine has collected, not market
   totals. Show `manifest.notes[0]` near any comparison between places. When
   `manifest.coverage.status` is `"partial"`, `coverage.incomplete_systems` lists
   which systems are only partly collected so far.
6. **Freshness.** Show "updated {manifest.generated_at}". Every listing was
   re-confirmed on the employer's board within
   `manifest.freshness.must_be_seen_within_days`. Treat a manifest older than 3
   days as stale.
7. **Rights.** Link every listing to its `url`, the employer's own posting. Don't
   copy job descriptions; none are provided. Show the Census notice from
   `manifest.rights.census_notice` somewhere in the app (an about or credits
   page is fine).

## Minimal client

```ts
const BASE = 'https://careerops-jobdata.pages.dev/v1';
const get = (p: string) => fetch(`${BASE}/${p}`).then((r) => (r.ok ? r.json() : null));

const manifest = await get('manifest.json');
if (!manifest?.export_version?.startsWith('1.')) throw new Error('unsupported job-data export');
const metros = await get('areas/metros.json');            // join on cbsa
// later, when a metro is opened:
const listings = (await get(`jobs/by-cbsa/${cbsa}.json`)) ?? [];
```

**Versioning.** Pin to `/v1/`. Additive fields keep `v1`, so ignore unknown
fields. Renames or changed meanings will ship as `/v2/`, alongside `v1`.
