Skip to content
SlashClone
Made by@eventcrew

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.

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