# Registered Robotaxi VIN Tracker

A mobile-first website that tracks how many Tesla Robotaxi vehicles are registered with the Texas DMV, lists every VIN, and remembers when each one first appeared.

**Live site:** https://registered-robotaxi.pages.dev

**Source data:** [TxDMV Tesla Robotaxi operator page](https://txmccs.txdmv.gov/automated-vehicles/operators/AV8313426653583)

---

## What you get

- A huge number at the top showing the current active VIN count
- A scrollable list of all VINs (newest first)
- "First seen" dates we track ourselves (TxDMV doesn't publish per-VIN dates)
- Removed VINs stay visible with a "Removed" badge if they disappear from TxDMV later
- "Last checked" timestamp and a link to the official TxDMV page
- Background checks every 15 minutes (once you configure cron)
- Runs entirely on Cloudflare's **free tier**

---

## Complete beginner guide (macOS)

This assumes you've never used Cloudflare, Wrangler, or deployed anything before. Follow every step in order.

### Step 0: What you need

- A Mac running macOS
- An internet connection
- A free Cloudflare account (we'll create this)
- About 20–30 minutes

You do **not** need a credit card for Cloudflare's free tier.

---

### Step 1: Install Homebrew (skip if you already have it)

Homebrew is the standard way to install developer tools on macOS.

Open **Terminal** (Spotlight → type `Terminal` → Enter) and run:

```bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```

When it finishes, it may tell you to run two `echo` / `eval` commands to add Homebrew to your PATH. **Copy and run those** if prompted.

Verify it worked:

```bash
brew --version
```

---

### Step 2: Install Node.js

Node.js includes `npm`, which we use to install Wrangler and run the project.

```bash
brew install node
```

Verify:

```bash
node --version   # should print something like v22.x.x
npm --version    # should print something like 10.x.x
```

---

### Step 3: Sign up for Cloudflare (free)

1. Go to https://dash.cloudflare.com/sign-up
2. Create an account with your email
3. Verify your email if asked
4. You do **not** need to add a domain or buy anything
5. You do **not** need to enter a credit card

Once logged in, you'll land on the Cloudflare dashboard. That's it — account created.

---

### Step 4: Get the project on your Mac

If you already have the code (e.g. cloned or copied into a folder), `cd` into it:

```bash
cd ~/Apps/registered-robotaxi
```

If you're starting from a git repo:

```bash
git clone <your-repo-url> registered-robotaxi
cd registered-robotaxi
```

Install the project's only dependency (Wrangler):

```bash
npm install
```

This creates a `node_modules/` folder. That's normal. Don't commit it.

---

### Step 5: Log Wrangler into Cloudflare

Wrangler is Cloudflare's command-line tool. It needs permission to talk to your account.

```bash
npx wrangler login
```

What happens:

1. Your browser opens to a Cloudflare login page
2. Click **Allow** to authorize Wrangler
3. Terminal prints something like `Successfully logged in`

If the browser doesn't open, copy the URL from Terminal and paste it into your browser manually.

Verify you're connected:

```bash
npx wrangler whoami
```

You should see your account email and account ID.

---

### Step 6: Create KV storage (where VIN history lives)

Cloudflare KV is a simple key-value database. We store one JSON blob with all VIN data.

Run **both** of these commands:

```bash
npx wrangler kv namespace create VIN_DATA
npx wrangler kv namespace create VIN_DATA --preview
```

Each command prints an `id = "..."` line. **Copy both IDs.**

Open `wrangler.toml` in any text editor and paste them in:

```toml
[[kv_namespaces]]
binding = "VIN_DATA"
id = "PASTE_PRODUCTION_ID_HERE"
preview_id = "PASTE_PREVIEW_ID_HERE"
```

- `id` = production (used when you deploy)
- `preview_id` = local dev (used when you run `npm run dev`)

Save the file.

---

### Step 7: Run it locally

Start the local dev server:

```bash
npm run dev
```

You should see:

```
Ready on http://localhost:8788
```

Leave that Terminal window running. Open a **second** Terminal window and seed the database (first time only — fetches real data from TxDMV):

```bash
curl -X POST http://localhost:8788/api/check
```

You should get back JSON with `"activeCount": 59` (or whatever the current count is).

Now open http://localhost:8788 in your browser. You should see the big number and the VIN list.

To stop the dev server: go back to the first Terminal window and press `Ctrl+C`.

---

### Step 8: Create the Cloudflare Pages project (first deploy only)

The first time you deploy, the project might not exist yet. Create it:

```bash
npx wrangler pages project create registered-robotaxi --production-branch main
```

You'll see:

```
Successfully created the 'registered-robotaxi' project.
It will be available at https://registered-robotaxi.pages.dev/
```

If you get "project already exists", that's fine — skip to Step 9.

---

### Step 9: Deploy to the internet

```bash
npm run deploy
```

This uploads your `public/` files and `functions/` server code to Cloudflare. When it finishes you'll see a URL like:

```
https://registered-robotaxi.pages.dev
```

(or a preview URL like `https://abc123.registered-robotaxi.pages.dev` — both work)

---

### Step 10: Seed production data (first deploy only)

The live site's database starts empty. Populate it once:

```bash
curl -X POST https://registered-robotaxi.pages.dev/api/check
```

Replace the URL with yours if different. You should get JSON back with the VIN count.

Now visit https://registered-robotaxi.pages.dev — the big number should show up.

---

### Step 11: Set up automatic checks every 15 minutes

**Cloudflare Pages does not support cron in its `wrangler.toml`** — `wrangler pages deploy` will fail if you add `[triggers]` there. The wrangler-native fix is a tiny **companion Worker** that POSTs to your Pages `/api/check` endpoint on a schedule.

Deploy the cron worker (one time, and again if you change the schedule):

```bash
npm run deploy:cron
```

This creates Worker `registered-robotaxi-cron` with cron `*/15 * * * *` (every 15 minutes). It calls `https://registered-robotaxi.pages.dev/api/check`, which runs the full sync (TxDMV fetch, KV update, ntfy notifications).

To deploy both the site and cron worker:

```bash
npm run deploy:all
```

Watch cron runs in real time:

```bash
npx wrangler tail registered-robotaxi-cron
```

Until the cron worker is deployed, the site still works — users can click **Refresh now** — but nothing updates in the background. **Push notifications also require cron** (or manual refresh) to detect new VINs.

---

### Step 12: Enable push notifications (optional)

When new VINs are registered with TxDMV, the server can send push alerts via [ntfy.sh](https://ntfy.sh). Notifications are sent server-side during sync — not from the browser.

#### A. Set the ntfy topic secret

The topic name is a shared secret. Store it in Cloudflare (do not commit it to git):

```bash
npx wrangler pages secret put NTFY_TOPIC --project-name registered-robotaxi
```

When prompted, paste your topic name (the random string from your ntfy URL, e.g. `zlt6oWzPdHxXRUOj` from `https://ntfy.sh/zlt6oWzPdHxXRUOj`).

For local dev, create `.dev.vars` in the project root (already gitignored):

```
NTFY_TOPIC=zlt6oWzPdHxXRUOj
```

Redeploy after setting the secret:

```bash
npm run deploy
```

#### B. Subscribe on your phone

1. Install **ntfy** from the [App Store](https://apps.apple.com/app/ntfy/id1625396347), [Play Store](https://play.google.com/store/apps/details?id=io.heckel.ntfy), or use the [web app](https://ntfy.sh)
2. Subscribe to your topic — open your topic URL (e.g. `https://ntfy.sh/zlt6oWzPdHxXRUOj`) and tap **Subscribe**
3. Allow notifications in your phone's OS settings

#### What triggers a notification

| Event | Notifies? |
|-------|-----------|
| First seed (`/api/check` on empty KV) | No (avoids blasting all ~59 VINs at once) |
| Cron or manual refresh finds new VIN(s) | Yes |
| Page load / `/api/data` poll | No |
| VIN removed from TxDMV | No |
| VIN re-added after removal | No |

---

### Step 13: Deploy updates later

Whenever you change code:

```bash
npm run deploy
```

That's it. No need to re-create KV or re-seed unless you wiped the database.

---

## Troubleshooting

| Problem | Fix |
|---------|-----|
| `wrangler: command not found` | Run commands with `npx wrangler` instead, or run `npm install` first |
| Browser didn't open during `wrangler login` | Copy the URL from Terminal and open it manually |
| `Project not found` on deploy | Run Step 8 first (`pages project create`) |
| Site shows `0` VINs / empty list | Run `curl -X POST https://your-site.pages.dev/api/check` |
| `Configuration file does not support "triggers"` | Remove `[triggers]` from the Pages `wrangler.toml` — use `npm run deploy:cron` for the companion Worker instead (Step 11) |
| Local dev shows empty data | Run `curl -X POST http://localhost:8788/api/check` while dev server is running |
| `npm install` errors | Make sure Node.js is installed (`node --version`) |
| No push notifications | Set `NTFY_TOPIC` secret, redeploy, confirm cron is active, subscribe to topic in ntfy app |

---

## Lessons learned (read this before you change anything)

These are real gotchas we hit building and deploying this project.

### 1. TxDMV is a React app, but the data is a plain JSON API

The public page at `txmccs.txdmv.gov` is a single-page app that shows "Loading, please wait..." if you try to scrape the HTML. **Don't scrape HTML.**

The actual data comes from:

```
GET https://txmccs.txdmv.gov/api/TruckStop/operators/AV8313426653583/vehicles
```

Response shape:

```json
{
  "vehicles": [
    { "vin": "7SAYGDEE5TF563340", "make": "TESLA", "model": "Model Y", "modelYear": 2026 }
  ]
}
```

No API key. No login. Just `fetch()` from a server-side function.

### 2. The API returns ALL VINs in one response

The TxDMV website paginates 20 VINs per page in the UI, but the API returns every VIN at once (59 as of initial deploy). **No pagination logic needed** on our side.

### 3. The API has no CORS headers — browser fetch will fail

If you try to call the TxDMV API directly from `app.js` in the browser, it will be blocked. That's why we have Pages Functions (`/api/check` and `/api/data`) that fetch server-side and proxy the result.

### 4. TxDMV doesn't tell us when a VIN was added

There's no `registeredAt` field per VIN. We track `firstSeen` ourselves: the timestamp of the first sync where that VIN appeared. New VINs bubble to the top of the list.

### 5. `[triggers]` cron in `wrangler.toml` does NOT work for Pages

We originally put this in `wrangler.toml`:

```toml
[triggers]
crons = ["*/15 * * * *"]
```

Deploy failed with:

```
Configuration file for Pages projects does not support "triggers"
```

**Fix:** Keep `[triggers]` out of the Pages `wrangler.toml`. Deploy the companion cron Worker with `npm run deploy:cron` (Step 11). Cron lives in `workers/cron/wrangler.toml`, not the Pages config.

### 6. You must create the Pages project before the first deploy

`wrangler pages deploy` fails with `Project not found` if the project doesn't exist yet. Run `wrangler pages project create` first (Step 8).

### 7. KV starts empty — seed it after first deploy

Cron won't help until there's a handler deployed, and the UI shows `0` until someone calls `/api/check` at least once. Always seed production after the first deploy (Step 10).

### 8. Free tier is plenty

- Cloudflare Pages: free, unlimited static requests
- Workers/Pages Functions: 100,000 requests/day free
- KV: 100,000 reads/day, 1,000 writes/day free
- 96 cron runs/day (every 15 min) + normal traffic is well within limits

---

## How it works (short version)

```
Browser → /api/data  → reads KV (fast, cached)
Browser → /api/check → fetches TxDMV API → merges into KV → returns JSON
Cron    → registered-robotaxi-cron Worker → POST /api/check
Sync    → ntfy.sh POST when new VIN(s) detected (not on first seed)
```

| Endpoint | Method | What it does |
|----------|--------|--------------|
| `/api/data` | GET | Return cached snapshot from KV (no TxDMV call) |
| `/api/check` | POST | Fetch TxDMV, merge state, write KV, return snapshot |

### VIN decoding

Each VIN is decoded client-side in the browser by `public/vin-decoder.js` using Tesla-specific position mappings (WMI, model, body, motor, year, plant). This runs automatically when the list renders — no extra API calls. Decoded details (e.g. plant, motor config) supplement the official TxDMV make/model/year line; they are derived from the VIN string, not from TxDMV.

### Push notifications

During sync, `functions/lib/notify.js` posts to [ntfy.sh](https://docs.ntfy.sh/publish/) when one or more genuinely new VINs appear (never seen before in KV). The topic is configured via the `NTFY_TOPIC` Pages secret. Subscribers receive push alerts on phone/desktop through the ntfy app. See Step 12 for setup.

---

## Project structure

```
registered-robotaxi/
├── public/
│   ├── index.html       # page shell
│   ├── styles.css       # mobile-first CSS, dark/light mode
│   ├── app.js           # fetches /api/data, renders UI, polls every 60s
│   ├── readme.html      # renders README.md in the browser (marked via cdnjs)
│   ├── readme.css       # markdown prose styles
│   ├── readme.js        # fetches /README.md and renders it
│   └── README.md        # copied from repo root on `npm run dev` / `npm run deploy`
├── functions/
│   ├── api/
│   │   ├── data.js      # GET handler
│   │   └── check.js     # POST handler
│   ├── _scheduled.js    # legacy Pages scheduled handler (unused; cron is in workers/cron/)
│   └── lib/
│       ├── sync.js      # TxDMV fetch + KV merge logic
│       └── notify.js    # ntfy.sh push on new VIN detection
├── wrangler.toml        # Pages config + KV binding
├── workers/
│   └── cron/            # companion Worker: wrangler-managed 15-min cron
│       ├── wrangler.toml
│       └── src/index.js
├── package.json
└── README.md
```

---

## Advanced: AI agent recreation spec

This section is written so another AI coding agent can rebuild this project from scratch without reading the source files.

### Goal

Build a mobile-first static site on **Cloudflare Pages (free tier)** that:

1. Displays the total count of active registered VINs for Tesla Robotaxi (AV operator `AV8313426653583`) as a large hero number
2. Lists all VINs, newest `firstSeen` first
3. Shows removed VINs with a badge (soft-delete, not hidden)
4. Shows `lastChecked` timestamp and link to source page
5. Proxies TxDMV API server-side (no CORS)
6. Persists state in Cloudflare KV
7. Supports manual refresh and 15-minute cron sync

### External APIs (no auth required)

```
OPERATOR_PAGE = https://txmccs.txdmv.gov/automated-vehicles/operators/AV8313426653583
VEHICLES_API  = https://txmccs.txdmv.gov/api/TruckStop/operators/AV8313426653583/vehicles
OPERATOR_API  = https://txmccs.txdmv.gov/api/TruckStop/operators/AV8313426653583
```

`VEHICLES_API` returns `{ vehicles: [{ vin, make, model, modelYear }] }` — all VINs in one response, no pagination.

`OPERATOR_API` returns `{ operator: { businessEntity: { legalName, dbaName, ... } } }` — used for company name display.

### KV data model

Single key: `state`

```json
{
  "lastChecked": "ISO-8601",
  "sourceUrl": "https://txmccs.txdmv.gov/automated-vehicles/operators/AV8313426653583",
  "operator": {
    "authorizationNumber": "AV8313426653583",
    "companyName": "Tesla Robotaxi, LLC"
  },
  "vehicles": {
    "VIN_STRING": {
      "vin": "string",
      "make": "string",
      "model": "string",
      "modelYear": "number",
      "firstSeen": "ISO-8601",
      "lastSeen": "ISO-8601",
      "removedAt": "ISO-8601 | null"
    }
  }
}
```

### Sync algorithm (`functions/lib/sync.js`)

On each sync:

1. `fetch(VEHICLES_API)` and `fetch(OPERATOR_API)` in parallel
2. Load existing KV state (or initialize empty)
3. For each VIN in API response:
   - **New:** set `firstSeen = now`, `lastSeen = now`, `removedAt = null`
   - **Existing:** update make/model/year, set `lastSeen = now`, clear `removedAt` if re-added
4. For each VIN in KV not in API response with `removedAt === null`: set `removedAt = now`
5. Set `lastChecked = now`, write KV
6. Return client snapshot: active VINs sorted by `firstSeen` desc, then removed sorted by `removedAt` desc

### Pages Functions routing (file-based)

| File | Export | Route | Method |
|------|--------|-------|--------|
| `functions/api/data.js` | `onRequestGet` | `/api/data` | GET |
| `functions/api/check.js` | `onRequestPost` | `/api/check` | POST |
| `functions/_scheduled.js` | `onSchedule` | (cron only) | — |

All handlers use `context.env.VIN_DATA` KV binding.

Client snapshot shape returned by `/api/data` and `/api/check`:

```json
{
  "lastChecked": "ISO-8601 | null",
  "sourceUrl": "string",
  "operator": { "authorizationNumber": "string", "companyName": "string" },
  "activeCount": "number",
  "vehicles": [{ "vin", "make", "model", "modelYear", "firstSeen", "lastSeen", "removedAt" }]
}
```

### `wrangler.toml` (Pages project)

```toml
name = "registered-robotaxi"
pages_build_output_dir = "public"
compatibility_date = "2024-09-23"

[[kv_namespaces]]
binding = "VIN_DATA"
id = "<production-kv-namespace-id>"
preview_id = "<preview-kv-namespace-id>"
```

**Do NOT include `[triggers]` in Pages wrangler.toml** — Pages rejects it. Use `workers/cron/` companion Worker with `npm run deploy:cron`.

### `package.json` scripts

```json
{
  "scripts": {
    "dev": "wrangler pages dev public",
    "deploy": "wrangler pages deploy public --project-name registered-robotaxi"
  },
  "devDependencies": {
    "wrangler": "^4.14.0"
  }
}
```

### Deployment sequence (exact commands)

```bash
npm install
npx wrangler login
npx wrangler kv namespace create VIN_DATA
npx wrangler kv namespace create VIN_DATA --preview
# paste IDs into wrangler.toml
npx wrangler pages project create registered-robotaxi --production-branch main
npm run deploy
curl -X POST https://registered-robotaxi.pages.dev/api/check
npm run deploy:cron
```

### Frontend requirements

- Vanilla HTML/CSS/JS, no framework
- Mobile-first, `prefers-color-scheme` dark/light support
- Hero count: `clamp(4.5rem, 22vw, 7rem)` font size, `font-weight: 800`
- Monospace VIN display
- "Refresh now" button → `POST /api/check`
- On load → `GET /api/data`; poll every 60 seconds
- Date formatting: `Intl.DateTimeFormat` with `timeZone: 'America/Chicago'`
- Removed VINs: reduced opacity + red "Removed" badge

### Critical constraints for agents

1. Never fetch TxDMV from browser JavaScript (CORS blocked)
2. Never scrape TxDMV HTML (SPA, useless without JS execution)
3. Never put `[triggers]` in Pages `wrangler.toml`
4. Always seed KV via `POST /api/check` after first deploy
5. Always create KV preview namespace for local dev
6. `firstSeen` is our own tracking — source API has no per-VIN dates
7. Hardcode `AUTH_NUMBER = 'AV8313426653583'` unless multi-operator is requested

### Verification checklist

- [ ] `curl -X POST http://localhost:8788/api/check` returns `activeCount` matching TxDMV (was 59 at build time)
- [ ] `curl http://localhost:8788/api/data` returns cached data without hitting TxDMV
- [ ] Production URL shows big number and full VIN list
- [ ] Cron worker deployed via `npm run deploy:cron` (`*/15 * * * *`)
- [ ] New VINs appear at top with today's `firstSeen` after sync
- [ ] Removed VINs show badge and stay in list
