File size: 15,788 Bytes
2b8a1f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6570b19
2b8a1f7
6570b19
 
 
 
 
2b8a1f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d39873c
2b8a1f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
/* Recallroll page. Talks to NHTSA directly from the browser (CORS-enabled public APIs). © 2026 CyberMax */
(function () {
  'use strict';
  var VPIC = 'https://vpic.nhtsa.dot.gov/api/vehicles/';
  var RECALLS = 'https://api.nhtsa.gov/recalls/recallsByVehicle';
  var $ = function (id) { return document.getElementById(id); };
  var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); };
  var cache = {};

  function getJson(url, opts) {
    if (!opts && cache[url]) return cache[url];
    var p = fetch(url, opts).then(function (r) { if (!r.ok) throw new Error('NHTSA returned ' + r.status); return r.json(); });
    if (!opts) { cache[url] = p; p.catch(function () { delete cache[url]; }); }
    return p;
  }

  // ---------- tabs ----------
  var tabs = { one: 'p-one', bulk: 'p-bulk', 'new': 'p-new' };
  function show(t) {
    Object.keys(tabs).forEach(function (k) { $('tab-' + k).classList.toggle('on', k === t); $(tabs[k]).hidden = k !== t; });
    if (t === 'new') loadSnapshot();
    try { history.replaceState(null, '', '#' + t); } catch (e) { /* file:// */ }
  }
  Object.keys(tabs).forEach(function (k) { $('tab-' + k).onclick = function () { show(k); }; });

  // ---------- recalls for make/model/year ----------
  // NHTSA's Recalls API only matches its own model spellings ("MUSTANG MACH E", "F-350 SD"); models/<year>.json
  // (built daily from NHTSA's recall file) maps each make to those names. It answers 400 when nothing matches.
  function modelIndex(year) { return getJson('models/' + encodeURIComponent(year) + '.json').catch(function () { return {}; }); }
  function recallsFor(make, model, year) {
    var q = function (m) {
      return fetch(RECALLS + '?make=' + encodeURIComponent(make) + '&model=' + encodeURIComponent(m) + '&modelYear=' + encodeURIComponent(year))
        .then(function (r) { if (r.status === 400) return { results: [] }; if (!r.ok) throw new Error('NHTSA returned ' + r.status); return r.json(); })
        .then(function (d) { return d.results || []; });
    };
    return modelIndex(year).then(function (idx) {
      var names = RR.matchModels(model, idx[String(make).toUpperCase()] || []);
      if (!names.length) names = [model]; // older campaigns (before 2010) aren't in the index: try vPIC's name
      return Promise.all(names.map(q)).then(function (all) { return { rows: RR.normalizeRecalls([].concat.apply([], all)), models: names }; });
    });
  }

  function recallCard(r) {
    var flags = (r.doNotDrive ? '<span class="pill red">Do Not Drive</span> ' : '') + (r.parkOutside ? '<span class="pill amber">Park Outside</span> ' : '') + (r.ota ? '<span class="pill">Over-the-air fix</span> ' : '');
    return '<article class="card' + (r.doNotDrive || r.parkOutside ? ' hot' : '') + '"><div class="card-h">' + flags + '<b>' + esc(r.component) + '</b></div>' +
      '<div class="muted small">Campaign <a href="https://www.nhtsa.gov/recalls?nhtsaId=' + esc(r.campaign) + '" target="_blank" rel="noopener">' + esc(r.campaign) + '</a> · reported ' + esc(r.date) + (r.manufacturer ? ' · ' + esc(r.manufacturer) : '') + '</div>' +
      (r.summary ? '<p>' + esc(r.summary) + '</p>' : '') +
      (r.consequence ? '<p><b>Risk:</b> ' + esc(r.consequence) + '</p>' : '') +
      (r.remedy ? '<p><b>Fix:</b> ' + esc(r.remedy) + '</p>' : '') + '</article>';
  }

  function recallBlock(v, res) {
    var n = res.rows.length, hot = res.rows.filter(function (r) { return r.doNotDrive || r.parkOutside; }).length;
    var head = '<h2>' + (n ? n + ' recall' + (n > 1 ? 's' : '') : 'No recalls') + ' listed for the ' + esc([v.year, v.make, res.models.join(' / ')].join(' ')) + '</h2>';
    if (hot) head += '<p class="msg bad"><b>' + hot + ' with a Do Not Drive or Park Outside advisory.</b> Check your exact VIN at <a href="https://www.nhtsa.gov/recalls" target="_blank" rel="noopener">nhtsa.gov/recalls</a> or call a dealer now.</p>';
    head += '<p class="muted small">Recalls apply to the model year; your exact vehicle may not be included, or may already be fixed. Confirm your VIN at <a href="https://www.nhtsa.gov/recalls" target="_blank" rel="noopener">nhtsa.gov/recalls</a>. Recall repairs are free at authorized dealers.</p>';
    var sorted = res.rows.slice().sort(function (a, b) { return (b.doNotDrive || b.parkOutside) - (a.doNotDrive || a.parkOutside); });
    return head + '<div class="cards">' + sorted.map(recallCard).join('') + '</div>' + NEXT;
  }
  // Right after a free result: the next step for people with more than one vehicle (counted links, checkout opens directly).
  var BUY = 'https://data.cybermax-tools.workers.dev/buy/';
  var NEXT = '<div class="msg next"><b>More than one vehicle?</b> The Bulk tab checks up to 50 VINs here for free. From your own system or a dealer/fleet list, the ' +
    '<a href="' + BUY + 'recallroll-api-pack?s=hub-recallroll-result" rel="nofollow">Recallroll API $5 pack</a> covers 400 calls of up to 10 VINs (4,000 VINs), no subscription; ' +
    'the key appears right after checkout. New recalls every Monday: <a href="' + BUY + 'recall-brief?s=hub-recallroll-result" rel="nofollow">Recallroll Brief</a>, $9/month.</div>';

  function vehicleBlock(v) {
    var rows = [['Vehicle', [v.year, v.make, v.model, v.trim].filter(Boolean).join(' ')], ['Series', v.series], ['Body', v.body], ['Type', v.type], ['Engine', v.engine], ['Drive', v.drive], ['GVWR', v.gvwr], ['Built in', v.plant], ['Manufacturer', v.manufacturer]];
    return '<div class="vcard"><div class="vin">' + esc(v.vin) + '</div><dl>' + rows.filter(function (r) { return r[1]; }).map(function (r) { return '<dt>' + r[0] + '</dt><dd>' + esc(r[1]) + '</dd>'; }).join('') + '</dl></div>';
  }

  function uniq(a) { var s = {}; return a.filter(function (x) { if (!x || s[x]) return false; s[x] = 1; return true; }).sort(); }

  function msg(el, text, kind) { el.hidden = !text; el.className = 'msg' + (kind ? ' ' + kind : ''); el.innerHTML = text || ''; }

  // ---------- one VIN ----------
  $('f-one').onsubmit = function (e) {
    e.preventDefault();
    var c = RR.checkVin($('vin').value), out = $('one-out'), m = $('one-msg');
    out.innerHTML = '';
    if (!c.ok) return msg(m, esc(c.error), 'bad');
    msg(m, c.warning ? esc(c.warning) + ' Decoding anyway…' : 'Decoding…', c.warning ? 'warn' : '');
    try { history.replaceState(null, '', '?vin=' + c.vin + '#one'); } catch (err) { /* ignore */ }
    getJson(VPIC + 'DecodeVinValues/' + c.vin + '?format=json').then(function (d) {
      var v = RR.vehicleFromVpic((d.Results || [])[0] || {});
      v.vin = c.vin;
      if (!v.decoded) { msg(m, 'NHTSA could not decode this VIN' + (v.errorText ? ': ' + esc(v.errorText) : '.') + ' Check it for typos.', 'bad'); return; }
      msg(m, c.warning ? esc(c.warning) : '', c.warning ? 'warn' : '');
      out.innerHTML = vehicleBlock(v) + '<div id="one-rec"><p class="muted">Loading recalls…</p></div>';
      return recallsFor(v.make, v.model, v.year).then(function (res) { $('one-rec').innerHTML = recallBlock(v, res); });
    }).catch(function (err) { msg(m, 'Could not reach NHTSA (' + esc(err.message) + '). Try again in a minute.', 'bad'); });
  };

  // ---------- search by year/make/model (only vehicles that have had a recall since 2010) ----------
  (function () {
    var yy = $('yy'), mk = $('mk'), md = $('md');
    getJson('models/years.json').then(function (d) {
      yy.innerHTML = '<option value="">Year</option>' + d.years.slice().reverse().map(function (y) { return '<option>' + esc(y) + '</option>'; }).join('');
    });
    yy.onchange = function () {
      mk.disabled = md.disabled = true; mk.innerHTML = '<option value="">Make</option>'; md.innerHTML = '<option value="">Model</option>';
      if (!yy.value) return;
      modelIndex(yy.value).then(function (idx) {
        mk.innerHTML = '<option value="">Make</option>' + Object.keys(idx).sort().map(function (m) { return '<option>' + esc(m) + '</option>'; }).join('');
        mk.disabled = false;
      });
    };
    mk.onchange = function () {
      md.disabled = true; md.innerHTML = '<option value="">Model</option>';
      if (!mk.value) return;
      modelIndex(yy.value).then(function (idx) {
        md.innerHTML = '<option value="">Model</option>' + uniq(idx[mk.value] || []).map(function (m) { return '<option>' + esc(m) + '</option>'; }).join('');
        md.disabled = false;
      });
    };
    md.onchange = function () {
      if (!md.value) return;
      var v = { year: yy.value, make: mk.value, model: md.value };
      $('one-out').innerHTML = '<p class="muted">Loading recalls…</p>'; msg($('one-msg'), '');
      recallsFor(v.make, v.model, v.year).then(function (res) { $('one-out').innerHTML = recallBlock(v, res); })
        .catch(function (err) { msg($('one-msg'), 'Could not reach NHTSA (' + esc(err.message) + ').', 'bad'); });
    };
  })();

  // ---------- bulk ----------
  var bulkRows = [];
  function pool(items, n, fn) {
    var i = 0, out = new Array(items.length);
    function next() { if (i >= items.length) return Promise.resolve(); var k = i++; return fn(items[k], k).then(function (r) { out[k] = r; }, function () { out[k] = null; }).then(next); }
    return Promise.all(Array.from({ length: Math.min(n, items.length) }, next)).then(function () { return out; });
  }
  $('bulk-go').onclick = function () {
    var vins = RR.extractVins($('vins').value, 50), st = $('bulk-status'), tb = document.querySelector('#bt tbody');
    if (!vins.length) { st.textContent = 'No 17-character VINs found.'; return; }
    st.textContent = 'Decoding ' + vins.length + ' VINs…'; $('bulk-csv').disabled = true; tb.innerHTML = ''; $('bulk-wrap').hidden = false;
    var body = new URLSearchParams({ format: 'json', data: vins.join(';') });
    getJson(VPIC + 'DecodeVINValuesBatch/', { method: 'POST', body: body }).then(function (d) {
      var vs = (d.Results || []).map(RR.vehicleFromVpic);
      var groups = {};
      vs.forEach(function (v) { if (v.decoded) groups[[v.year, v.make, v.model].join('|')] = v; });
      var keys = Object.keys(groups);
      st.textContent = 'Checking recalls for ' + keys.length + ' model years…';
      return pool(keys, 4, function (k) { var v = groups[k]; return recallsFor(v.make, v.model, v.year); }).then(function (res) {
        var byKey = {};
        keys.forEach(function (k, i) { byKey[k] = res[i]; });
        bulkRows = vs.map(function (v) {
          var r = byKey[[v.year, v.make, v.model].join('|')], rows = r ? r.rows : [];
          var dnd = rows.filter(function (x) { return x.doNotDrive; }).length, po = rows.filter(function (x) { return x.parkOutside; }).length;
          return { vin: v.vin, year: v.year, make: v.make, model: v.model, trim: v.trim, decoded: v.decoded, recalls: v.decoded && r ? rows.length : '', do_not_drive: dnd, park_outside: po,
            newest_campaign: rows[0] ? rows[0].campaign : '', newest_date: rows[0] ? rows[0].date : '', newest_component: rows[0] ? rows[0].component : '',
            campaigns: rows.map(function (x) { return x.campaign; }).join(' '), note: !v.decoded ? (v.errorText || 'not decoded') : (r ? '' : 'recall lookup failed') };
        });
        bulkRows.sort(function (a, b) { return (b.do_not_drive + b.park_outside) - (a.do_not_drive + a.park_outside) || (+b.recalls || 0) - (+a.recalls || 0); });
        tb.innerHTML = bulkRows.map(function (r) {
          var warn = (r.do_not_drive ? '<span class="pill red">Do Not Drive ×' + r.do_not_drive + '</span> ' : '') + (r.park_outside ? '<span class="pill amber">Park Outside ×' + r.park_outside + '</span>' : '');
          return '<tr><td>' + (r.decoded ? esc([r.year, r.make, r.model, r.trim].filter(Boolean).join(' ')) : '<span class="muted">' + esc(r.note) + '</span>') + '</td><td class="num">' + esc(r.recalls) + '</td><td>' + warn + '</td><td class="mono">' + esc(r.vin) + '</td><td>' + (r.newest_campaign ? esc(r.newest_date) + ' <span class="sub">' + esc(r.newest_component) + '</span>' : '') + '</td></tr>';
        }).join('');
        var hot = bulkRows.filter(function (r) { return r.do_not_drive || r.park_outside; }).length;
        st.textContent = bulkRows.length + ' VINs checked · ' + bulkRows.filter(function (r) { return +r.recalls > 0; }).length + ' with recalls listed for their model year · ' + hot + ' with a Do Not Drive / Park Outside advisory. Confirm each VIN at nhtsa.gov/recalls.';
        $('bulk-csv').disabled = false;
      });
    }).catch(function (err) { st.textContent = 'Could not reach NHTSA (' + err.message + '). Try again.'; });
  };
  $('bulk-csv').onclick = function () {
    var csv = RR.toCsv(bulkRows, ['vin', 'year', 'make', 'model', 'trim', 'recalls', 'do_not_drive', 'park_outside', 'newest_campaign', 'newest_date', 'newest_component', 'campaigns', 'note']);
    var a = document.createElement('a');
    a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
    a.download = 'recallroll-' + new Date().toISOString().slice(0, 10) + '.csv';
    document.body.appendChild(a); a.click(); a.remove();
  };

  // ---------- new recalls (snapshot) ----------
  var snap = null;
  function loadSnapshot() {
    if (snap) return;
    snap = {};
    fetch('data.json').then(function (r) { return r.json(); }).then(function (d) {
      snap = d;
      var m = d.meta, fmt = function (n) { return Number(n).toLocaleString('en-US'); };
      $('stats').innerHTML = [[fmt(m.campaigns_30d), 'recall campaigns in the last 30 days'], [fmt(m.campaigns), 'in the last 12 months'], [fmt(m.units_affected), 'units potentially affected (12 mo.)'], [fmt(m.do_not_drive + m.park_outside), 'Do Not Drive / Park Outside (12 mo.)']]
        .map(function (s) { return '<div class="stat"><b>' + s[0] + '</b><span>' + s[1] + '</span></div>'; }).join('');
      var makes = {};
      d.recent.forEach(function (c) { c.makes.split(', ').forEach(function (k) { makes[k] = 1; }); });
      $('nmake').innerHTML = '<option value="">All makes</option>' + Object.keys(makes).sort().map(function (k) { return '<option>' + esc(k) + '</option>'; }).join('');
      renderNew();
    }).catch(function () { $('ncount').textContent = 'Could not load the recall snapshot.'; snap = null; });
  }
  function renderNew() {
    if (!snap || !snap.recent) return;
    var flag = $('nflag').checked;
    var list = flag ? RR.filterRecent(snap.advisories, $('nq').value, $('nmake').value) : RR.filterRecent(snap.recent, $('nq').value, $('nmake').value);
    $('ncount').textContent = (flag ? list.length + ' Do Not Drive / Park Outside campaigns in the last 5 years' : list.length + ' campaigns received in the last 60 days') + ' · NHTSA data as of ' + snap.meta.latest_received + ' (built ' + snap.meta.built + ')';
    $('nlist').innerHTML = list.slice(0, 200).map(function (c) {
      return recallCard({ campaign: c.campaign, date: c.report_received, component: c.vehicles, manufacturer: c.manufacturer + (c.units_affected ? ' · ' + Number(c.units_affected).toLocaleString('en-US') + ' affected' : ''), summary: c.component + (c.summary ? ': ' + c.summary : ''), consequence: c.consequence, remedy: c.remedy, doNotDrive: c.do_not_drive, parkOutside: c.park_outside });
    }).join('');
  }
  ['nq', 'nmake', 'nflag'].forEach(function (id) { $(id).addEventListener('input', renderNew); $(id).addEventListener('change', renderNew); });

  // ---------- deep links: ?vin=...  #bulk  #new ----------
  var qv = new URLSearchParams(location.search).get('vin');
  var h = (location.hash || '').slice(1);
  if (tabs[h]) show(h);
  if (qv) { $('vin').value = qv; show('one'); $('f-one').requestSubmit ? $('f-one').requestSubmit() : $('f-one').onsubmit(new Event('submit')); }
})();