Shipment tracker
@eventcrew/shipment-tracker
Track booth gear and swag shipments for events from a CSV or a pasted list of tracking numbers. Detects UPS, FedEx, USPS, and DHL from the number, builds a tracker table with tracking links, and flags anything at risk of missing its needed-by date. Works offline.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Builds a shipment tracker for event gear from a CSV or a pasted list of tracking numbers.
- 3// Usage: node scripts/track.mjs [data/shipments.csv|data/shipments.txt|-] [out-dir]
- 4// [--as-of YYYY-MM-DD] [--risk-days 3]
- 5// Writes: <out-dir>/shipments.md, <out-dir>/shipments.csv, <out-dir>/shipments.json
- 6//
- 7// CSV columns (header names are flexible, only tracking is required):
- 8// tracking, item, destination, needed_by, carrier, status, eta, shipped, notes
- 9// Pasted list: one shipment per line, fields split by tab, comma, or " | " in the order
- 10// tracking, item, destination, needed_by
- 11// Works offline. It detects the carrier from the number format and builds tracking links
- 12// for a person to open; it never contacts a carrier.
- 13import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
- 14import { join } from "node:path";
- 16const args = process.argv.slice(2);
- 17function flag(name, fallback) {
- 18 const i = args.indexOf(name);
- 19 if (i === -1) return fallback;
- 20 const value = args[i + 1] ?? "";
- 21 args.splice(i, 2);
- 22 return value;
- 23}
- 24const asOf = flag("--as-of", new Date().toISOString().slice(0, 10));
- 25const riskDays = Number(flag("--risk-days", "3"));
- 26const [input = "data/shipments.csv", outDir = "out"] = args;
- 28function fail(msg) {
- 29 console.error(`track: ${msg}`);
- 30 process.exit(1);
- 31}
- 32if (!/^\d{4}-\d{2}-\d{2}$/.test(asOf)) fail(`--as-of must be YYYY-MM-DD (got "${asOf}")`);
- 33if (!Number.isFinite(riskDays) || riskDays < 0) fail("--risk-days must be a number of days, like 3");
- 35let text;
- 36if (input === "-") text = readFileSync(0, "utf8");
- 37else if (!existsSync(input)) fail(`${input} not found. Save your list there, or pass "-" and paste it on stdin.`);
- 38else text = readFileSync(input, "utf8");
- 40// ---- parsing ---------------------------------------------------------------
- 41function parseCsv(src) {
- 42 const rows = [];
- 43 let row = [];
- 44 let field = "";
- 45 let quoted = false;
- 46 const push = () => {
- 47 row.push(field);
- 48 field = "";
- 49 if (row.some((v) => v.trim() !== "")) rows.push(row);
- 50 row = [];
- 51 };
- 52 for (let i = 0; i < src.length; i++) {
- 53 const ch = src[i];
- 54 if (quoted) {
- 55 if (ch === '"' && src[i + 1] === '"') {
- 56 field += '"';
- 57 i++;
- 58 } else if (ch === '"') quoted = false;
- 59 else field += ch;
- 60 } else if (ch === '"') quoted = true;
- 61 else if (ch === ",") {
- 62 row.push(field);
- 63 field = "";
- 64 } else if (ch === "\n" || ch === "\r") {
- 65 if (ch === "\r" && src[i + 1] === "\n") i++;
- 66 push();
- 67 } else field += ch;
- 68 }
- 69 push();
- 70 return rows;
- 71}
- 73const HEADER_ALIASES = {
- 74 tracking: ["tracking", "tracking number", "tracking_number", "tracking #", "tracking no", "number", "awb"],
- 75 item: ["item", "items", "contents", "description", "what"],
- 76 destination: ["destination", "dest", "to", "ship to", "ship_to", "venue", "event"],
- 77 needed_by: ["needed_by", "needed by", "need by", "due", "due date", "deadline", "in hand", "in_hand"],
- 78 carrier: ["carrier", "courier", "shipper"],
- 79 status: ["status", "state"],
- 80 eta: ["eta", "expected", "expected delivery", "delivery date", "est delivery"],
- 81 shipped: ["shipped", "ship date", "ship_date", "sent"],
- 82 notes: ["notes", "note", "comments"],
- 83};
- 84function canonical(header) {
- 85 const h = header.trim().toLowerCase();
- 86 for (const [key, names] of Object.entries(HEADER_ALIASES)) if (names.includes(h)) return key;
- 87 return null;
- 88}
- 90function parseInput(src) {
- 91 const lines = src.split(/\r?\n/).filter((l) => l.trim() !== "" && !l.trim().startsWith("#"));
- 92 if (lines.length === 0) return [];
- 93 const firstCells = parseCsv(lines[0])[0] ?? [];
- 94 const hasHeader = firstCells.some((c) => canonical(c) === "tracking");
- 95 if (hasHeader) {
- 96 const [header, ...body] = parseCsv(src);
- 97 const keys = header.map(canonical);
- 98 return body.map((cells) => {
- 99 const rec = {};
- 100 keys.forEach((k, i) => {
- 101 if (k) rec[k] = (cells[i] ?? "").trim();
- 102 });
- 103 return rec;
- 104 });
- 105 }
- 106 // Pasted list: tab, " | ", or comma separated, positional.
- 107 return lines.map((line) => {
- 108 const parts = line.includes("\t")
- 109 ? line.split("\t")
- 110 : line.includes("|")
- 111 ? line.split("|")
- 112 : line.split(",");
- 113 const [tracking = "", item = "", destination = "", needed_by = ""] = parts.map((p) => p.trim());
- 114 return { tracking, item, destination, needed_by };
- 115 });
- 116}
- 118// ---- carriers --------------------------------------------------------------
- 119// Tracking links open the carrier's public tracking page in a browser. This script only
- 120// builds the links; it never calls these sites.
- 121const CARRIERS = {
- 122 UPS: "https://www.ups.com/track?tracknum=",
- 123 FedEx: "https://www.fedex.com/fedextrack/?trknbr=",
- 124 USPS: "https://tools.usps.com/go/TrackConfirmAction?tLabels=",
- 125 DHL: "https://www.dhl.com/global-en/home/tracking/tracking-express.html?submit=1&tracking-id=",
- 126};
- 127function trackingUrl(carrier, number) {
- 128 const c = CARRIERS[carrier];
- 129 return c ? `${c}${encodeURIComponent(number)}` : "";
- 130}
- 132function detectCarrier(raw) {
- 133 const n = raw.replace(/[\s-]/g, "").toUpperCase();
- 134 if (/^1Z[0-9A-Z]{16}$/.test(n)) return { carrier: "UPS", confidence: "high", number: n };
- 135 if (/^T\d{10}$/.test(n)) return { carrier: "UPS", confidence: "medium", number: n };
- 136 // Universal Postal Union format: two letters, nine digits, country code.
- 137 const upu = /^[A-Z]{2}\d{9}([A-Z]{2})$/.exec(n);
- 138 if (upu) {
- 139 if (upu[1] === "US") return { carrier: "USPS", confidence: "high", number: n };
- 140 return { carrier: "", confidence: "low", number: n, note: `international mail from ${upu[1]}; track with that country's post` };
- 141 }
- 142 if (/^(?:JJD|JVGL)\d{9,}$/.test(n) || /^GM\d{16,18}$/.test(n)) return { carrier: "DHL", confidence: "high", number: n };
- 143 if (/^420\d{5}(\d{4})?9\d{21}$/.test(n)) return { carrier: "USPS", confidence: "high", number: n.replace(/^420\d{5}(\d{4})?/, "") };
- 144 if (/^9[2-5]\d{18,24}$/.test(n)) return { carrier: "USPS", confidence: "high", number: n };
- 145 if (/^96\d{20}$/.test(n)) return { carrier: "FedEx", confidence: "high", number: n };
- 146 if (/^(?:\d{12}|\d{15})$/.test(n)) return { carrier: "FedEx", confidence: "high", number: n };
- 147 if (/^\d{20}$/.test(n)) return { carrier: "FedEx", confidence: "medium", number: n };
- 148 if (/^\d{10}$/.test(n)) return { carrier: "DHL", confidence: "medium", number: n };
- 149 if (/^\d{22}$/.test(n)) return { carrier: "USPS", confidence: "medium", number: n };
- 150 return { carrier: "", confidence: "low", number: n, note: "unknown format; check the number or set the carrier column" };
- 151}
- 152function normalizeCarrier(s) {
- 153 const v = String(s ?? "").trim().toLowerCase();
- 154 if (!v) return "";
- 155 if (v.startsWith("ups")) return "UPS";
- 156 if (v.startsWith("fedex") || v.startsWith("fed ex")) return "FedEx";
- 157 if (v.startsWith("usps") || v.includes("postal")) return "USPS";
- 158 if (v.startsWith("dhl")) return "DHL";
- 159 return s.trim();
- 160}
- 162// ---- dates and status ------------------------------------------------------
- 163const MONTHS = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 };
- 164function parseDate(s) {
- 165 const v = String(s ?? "").trim();
- 166 if (!v) return null;
- 167 let m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(v);
- 168 if (m) return iso(+m[1], +m[2], +m[3]);
- 169 m = /^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/.exec(v);
- 170 if (m) {
- 171 const year = m[3] ? (m[3].length === 2 ? 2000 + +m[3] : +m[3]) : +asOf.slice(0, 4);
- 172 return iso(year, +m[1], +m[2]);
- 173 }
- 174 m = /^([a-z]{3})[a-z]*\.?\s+(\d{1,2})(?:,?\s+(\d{4}))?$/i.exec(v);
- 175 if (m && MONTHS[m[1].toLowerCase()]) return iso(m[3] ? +m[3] : +asOf.slice(0, 4), MONTHS[m[1].toLowerCase()], +m[2]);
- 176 return null;
- 177}
- 178function iso(y, mo, d) {
- 179 const dt = new Date(Date.UTC(y, mo - 1, d));
- 180 if (dt.getUTCMonth() !== mo - 1) return null;
- 181 return dt.toISOString().slice(0, 10);
- 182}
- 183function daysBetween(a, b) {
- 184 return Math.round((Date.parse(b) - Date.parse(a)) / 86400000);
- 185}
- 186function normalizeStatus(s) {
- 187 const v = String(s ?? "").trim().toLowerCase();
- 188 if (!v) return "not checked";
- 189 if (/deliver(ed)?$|^delivered|signed/.test(v) && !/out for/.test(v)) return "delivered";
- 190 if (/out for delivery/.test(v)) return "out for delivery";
- 191 if (/exception|delay|held|hold|customs|return|damag|lost|refused|address/.test(v)) return "exception";
- 192 if (/transit|picked up|departed|arrived|on the way|shipped|moving/.test(v)) return "in transit";
- 193 if (/label|created|pre-?shipment|pending|not yet/.test(v)) return "label created";
- 194 return v;
- 195}
- 197// ---- build -----------------------------------------------------------------
- 198const records = parseInput(text);
- 199if (records.length === 0) fail(`${input} has no shipments. Expected a header row with "tracking", or one tracking number per line.`);
- 201const RISK_ORDER = { late: 0, "at risk": 1, "check number": 2, "no date": 3, "on track": 4, delivered: 5 };
- 202const shipments = records.map((r, idx) => {
- 203 const raw = r.tracking ?? "";
- 204 const detected = detectCarrier(raw);
- 205 const declared = normalizeCarrier(r.carrier);
- 206 const carrier = declared || detected.carrier;
- 207 const status = normalizeStatus(r.status);
- 208 const neededBy = parseDate(r.needed_by);
- 209 const eta = parseDate(r.eta);
- 210 const reasons = [];
- 211 if (declared && detected.carrier && declared !== detected.carrier) {
- 212 reasons.push(`number looks like ${detected.carrier}, listed as ${declared}`);
- 213 }
- 214 if (detected.note && !declared) reasons.push(detected.note);
- 215 if (r.needed_by && !neededBy) reasons.push(`could not read needed-by date "${r.needed_by}"`);
- 216 if (r.eta && !eta) reasons.push(`could not read ETA "${r.eta}"`);
- 218 const daysLeft = neededBy ? daysBetween(asOf, neededBy) : null;
- 219 let risk;
- 220 if (status === "delivered") risk = "delivered";
- 221 else if (!raw.trim() || (!carrier && detected.confidence === "low")) risk = "check number";
- 222 else if (!neededBy) risk = "no date";
- 223 else if (daysLeft < 0) {
- 224 risk = "late";
- 225 reasons.push(`needed ${-daysLeft} day(s) ago`);
- 226 } else if (eta && eta > neededBy) {
- 227 risk = "late";
- 228 reasons.push(`ETA ${eta} is after needed-by ${neededBy}`);
- 229 } else if (status === "exception") {
- 230 risk = "at risk";
- 231 reasons.push("carrier reports an exception");
- 232 } else if (daysLeft <= riskDays && status !== "out for delivery") {
- 233 risk = "at risk";
- 234 reasons.push(daysLeft === 0 ? "needed today" : `needed in ${daysLeft} day(s), status: ${status}`);
- 235 } else if (status === "label created" && daysLeft <= riskDays + 2) {
- 236 risk = "at risk";
- 237 reasons.push(`label created but not moving, needed in ${daysLeft} day(s)`);
- 238 } else risk = "on track";
- 240 return {
- 241 row: idx + 1,
- 242 tracking: detected.number || raw.trim(),
- 243 carrier: carrier || "unknown",
- 244 carrier_confidence: declared ? "set" : detected.confidence,
- 245 item: r.item ?? "",
- 246 destination: r.destination ?? "",
- 247 needed_by: neededBy ?? (r.needed_by || ""),
- 248 days_left: daysLeft,
- 249 status,
- 250 eta: eta ?? (r.eta || ""),
- 251 risk,
- 252 reasons,
- 253 url: trackingUrl(carrier, detected.number || raw.trim()),
- 254 notes: r.notes ?? "",
- 255 };
- 256});
- 258shipments.sort(
- 259 (a, b) =>
- 260 RISK_ORDER[a.risk] - RISK_ORDER[b.risk] ||
- 261 (a.days_left ?? 9999) - (b.days_left ?? 9999) ||
- 262 a.row - b.row,
- 263);
- 265const summary = { total: shipments.length, late: 0, at_risk: 0, check_number: 0, no_date: 0, on_track: 0, delivered: 0 };
- 266for (const s of shipments) summary[s.risk.replace(/ /g, "_")]++;
- 267const byCarrier = {};
- 268for (const s of shipments) byCarrier[s.carrier] = (byCarrier[s.carrier] ?? 0) + 1;
- 270// ---- write -----------------------------------------------------------------
- 271mkdirSync(outDir, { recursive: true });
- 272const csvCell = (v) => {
- 273 const s = String(v ?? "");
- 274 return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
- 275};
- 276const CSV_COLS = ["tracking", "carrier", "item", "destination", "needed_by", "days_left", "status", "eta", "risk", "reasons", "url", "notes"];
- 277const csv = [CSV_COLS.join(",")]
- 278 .concat(shipments.map((s) => CSV_COLS.map((c) => csvCell(c === "reasons" ? s.reasons.join("; ") : s[c])).join(",")))
- 279 .join("\n");
- 280writeFileSync(join(outDir, "shipments.csv"), `${csv}\n`);
- 282const FLAG = { late: "LATE", "at risk": "AT RISK", "check number": "CHECK #", "no date": "NO DATE", "on track": "ok", delivered: "delivered" };
- 283const mdCell = (v) => String(v ?? "").replace(/\|/g, "/").replace(/\n/g, " ");
- 284const md = [
- 285 `# Shipment tracker (as of ${asOf})`,
- 286 "",
- 287 `${summary.total} shipments: ${summary.late} late, ${summary.at_risk} at risk, ${summary.check_number} need a number check, ${summary.no_date} with no needed-by date, ${summary.on_track} on track, ${summary.delivered} delivered.`,
- 288 `At risk means needed within ${riskDays} day(s) and not yet out for delivery, an exception, or an ETA after the needed-by date.`,
- 289 "",
- 290 "| Flag | Item | Destination | Needed by | Days left | Carrier | Status | Tracking | Why |",
- 291 "| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
- 292 ...shipments.map(
- 293 (s) =>
- 294 `| ${FLAG[s.risk]} | ${mdCell(s.item)} | ${mdCell(s.destination)} | ${s.needed_by} | ${s.days_left ?? ""} | ${s.carrier} | ${s.status} | ${s.url ? `[${s.tracking}](${s.url})` : s.tracking} | ${mdCell(s.reasons.join("; "))} |`,
- 295 ),
- 296 "",
- 297].join("\n");
- 298writeFileSync(join(outDir, "shipments.md"), md);
- 299writeFileSync(
- 300 join(outDir, "shipments.json"),
- 301 `${JSON.stringify({ as_of: asOf, risk_days: riskDays, summary, by_carrier: byCarrier, shipments }, null, 2)}\n`,
- 302);
- 304console.log(md);
- 305console.log(`Wrote ${join(outDir, "shipments.md")}, ${join(outDir, "shipments.csv")}, ${join(outDir, "shipments.json")}`);
