From 077dc35cf2e25cb814b2f3e719e4a86dc5bb925a Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 28 Aug 2026 12:41:59 +0200 Subject: [PATCH] refactor(imports): extract shared import-parse helpers + add parseTime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves normHeader/resolveColumn/parseDate/toNumber out of fuel-import-parse into a new src/lib/import-parse.ts so plant-hours can reuse them. Adds parseTime (HH:MM, 24h, single-digit hour normalised). Adds the PLANT_COL_ALIASES table for the plant-hours route. fuel-import-parse becomes a thin re-export shim — the fuel-logs route and its existing test file are unchanged. --- src/lib/fuel-import-parse.ts | 110 ++++++------------------------- src/lib/import-parse.test.ts | 34 ++++++++++ src/lib/import-parse.ts | 124 +++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 91 deletions(-) create mode 100644 src/lib/import-parse.test.ts create mode 100644 src/lib/import-parse.ts diff --git a/src/lib/fuel-import-parse.ts b/src/lib/fuel-import-parse.ts index f8d9d56..143809a 100644 --- a/src/lib/fuel-import-parse.ts +++ b/src/lib/fuel-import-parse.ts @@ -1,100 +1,28 @@ /** - * Pure parsing helpers for the fuel-logs import route. Extracted from - * the route so they can be unit-tested without spinning up PocketBase - * or the Next.js request lifecycle. + * Legacy module — re-exports the shared helpers from + * src/lib/import-parse.ts. The fuel-logs import route still imports + * `COL_ALIASES`, `normHeader`, `resolveColumn`, `parseDate`, `toNumber` + * from this path; new code should import from "./import-parse" directly. * - * All four helpers are stateless and side-effect free. + * `resolveColumn` is wrapped so that the legacy two-arg signature + * (`resolveColumn(headers, key)`) keeps using FUEL_COL_ALIASES, matching + * the original fuel-import-parse behavior. The new import-parse.resolveColumn + * defaults to PLANT_COL_ALIASES for the plant-hours route. */ +import { + normHeader, + resolveColumn as resolveColumnImpl, + parseDate, + toNumber, +} from "./import-parse"; +import { FUEL_COL_ALIASES } from "./import-parse"; -// Aliases - order doesn't matter (we do case-insensitive equality), -// but each list is documented by its primary header. -export const COL_ALIASES: Record = { - date: ["date logged", "date_logged", "date"], - vehicle_qr: ["vehicle qr", "vehicle_qr", "qr", "qr code id", "qr_code_id"], - vehicle_desc: ["vehicle", "vehicle description", "vehicle_description", "description"], - odo: ["km's/ hours", "kms/ hours", "km/hours", "odo_reading", "odometer", "hour_meter", "hour meter"], - litres: ["litres pumped", "litres", "liters"], - site: ["current site", "site", "current_site"], - consumption: ["latest consumption", "latest_consumption", "consumption"], -}; +export const COL_ALIASES = FUEL_COL_ALIASES; +export { normHeader, parseDate, toNumber }; -/** Normalise a header for fuzzy matching. */ -export function normHeader(s: unknown): string { - return typeof s === "string" - ? s.replace(/\s+/g, " ").trim().toLowerCase() - : ""; -} - -/** - * Resolve the original header text for a logical column. - * Returns the EXACT header string the spreadsheet used (so the row - * lookup uses the same key the source file provided), or null if no - * alias matched. - */ export function resolveColumn( headers: string[], - key: keyof typeof COL_ALIASES, + key: keyof typeof FUEL_COL_ALIASES, ): string | null { - const target = COL_ALIASES[key].map(normHeader); - for (const h of headers) { - if (target.includes(normHeader(h))) return h; - } - return null; -} - -/** - * Parse the date column into an ISO `YYYY-MM-DD` string. - * Primary format: `DD/MM/YYYY` (South African pump sheets). Also - * handles a leading space. Falls back to `YYYY-MM-DD` for legacy CSV, - * and to Excel date serials (numbers > 25569). - * Returns null if none of those formats match. - */ -export function parseDate(input: unknown): string | null { - if (input === null || input === undefined) return null; - // Excel serial number (SheetJS with raw:true can return these for - // date-formatted cells). 25569 = 1970-01-01 in Excel's epoch. - if (typeof input === "number" && Number.isFinite(input) && input > 25569) { - const d = new Date(Math.round((input - 25569) * 86400 * 1000)); - if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10); - } - const s = String(input).trim(); - if (!s) return null; - // DD/MM/YYYY (tolerates D/M/YYYY) - let m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(s); - if (m) { - const dd = Number(m[1]); - const mm = Number(m[2]); - const yyyy = Number(m[3]); - if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) { - const iso = new Date(Date.UTC(yyyy, mm - 1, dd)); - if (!Number.isNaN(iso.getTime())) return iso.toISOString().slice(0, 10); - } - return null; - } - // YYYY-MM-DD (legacy CSV) - m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s); - if (m) { - const iso = new Date( - Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])), - ); - if (!Number.isNaN(iso.getTime())) return iso.toISOString().slice(0, 10); - } - return null; -} - -/** - * Coerce a cell value to a finite number, or null if empty / non-numeric. - * Empty cells (Excel blank, "") return null - the caller decides whether - * that's an error (required fields) or "leave undefined" (optional). - */ -export function toNumber(input: unknown): number | null { - if (input === null || input === undefined) return null; - if (typeof input === "number") return Number.isFinite(input) ? input : null; - if (typeof input === "string") { - const trimmed = input.trim(); - if (!trimmed) return null; - const n = Number(trimmed); - return Number.isFinite(n) ? n : null; - } - return null; + return resolveColumnImpl(headers, key, FUEL_COL_ALIASES); } diff --git a/src/lib/import-parse.test.ts b/src/lib/import-parse.test.ts new file mode 100644 index 0000000..c6c0f2b --- /dev/null +++ b/src/lib/import-parse.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { parseTime } from "./import-parse"; + +describe("parseTime", () => { + it("accepts HH:MM (24h)", () => { + expect(parseTime("06:00")).toBe("06:00"); + expect(parseTime("17:30")).toBe("17:30"); + expect(parseTime("23:59")).toBe("23:59"); + expect(parseTime("00:00")).toBe("00:00"); + }); + + it("trims whitespace", () => { + expect(parseTime(" 06:00 ")).toBe("06:00"); + }); + + it("normalises single-digit hour to two digits", () => { + expect(parseTime("6:00")).toBe("06:00"); + expect(parseTime("9:05")).toBe("09:05"); + }); + + it("returns null for empty / null / undefined", () => { + expect(parseTime("")).toBeNull(); + expect(parseTime(null)).toBeNull(); + expect(parseTime(undefined)).toBeNull(); + }); + + it("returns null for malformed times", () => { + expect(parseTime("25:00")).toBeNull(); // hour out of range + expect(parseTime("12:60")).toBeNull(); // minute out of range + expect(parseTime("12:5")).toBeNull(); // minute must be 2 digits + expect(parseTime("not a time")).toBeNull(); + expect(parseTime("12-00")).toBeNull(); // wrong separator + }); +}); \ No newline at end of file diff --git a/src/lib/import-parse.ts b/src/lib/import-parse.ts new file mode 100644 index 0000000..e3e1e49 --- /dev/null +++ b/src/lib/import-parse.ts @@ -0,0 +1,124 @@ +/** + * Shared parsing helpers for the import routes (fuel-logs, plant-hours). + * Extracted from src/lib/fuel-import-parse.ts so that plant-hours can + * reuse the column-resolution + date-parsing logic and add a new + * parseTime helper without duplicating code. + * + * All four helpers are stateless and side-effect free. fuel-import-parse + * re-exports the three legacy helpers so existing consumers keep working. + */ + +/** Header aliases for the plant-hours import (13 logical columns). */ +export const PLANT_COL_ALIASES: Record = { + date_logged: ["date logged", "date_logged", "date"], + vehicle_qr: ["vehicle qr", "vehicle_qr", "qr", "qr_code_id"], + vehicle_desc: ["vehicle", "vehicle description", "vehicle_description", "description"], + start_date: ["start date", "start_date"], + end_date: ["end date", "end_date"], + start_time: ["start time", "start_time"], + end_time: ["end time", "end_time"], + start_hours: ["start hours", "start_hours"], + end_hours: ["end hours", "end_hours"], + hours: ["hours"], + notes: ["notes"], + hour_meter: ["hour meter", "hour_meter", "hour_meter_budget", "budget"], + cost_per_hour: ["cost / hr", "cost/hr", "cost_per_hour", "cost"], +}; + +/** Header aliases for the fuel-logs import (legacy, kept for the fuel route). */ +export const FUEL_COL_ALIASES: Record = { + date: ["date logged", "date_logged", "date"], + vehicle_qr: ["vehicle qr", "vehicle_qr", "qr", "qr code id", "qr_code_id"], + vehicle_desc: ["vehicle", "vehicle description", "vehicle_description", "description"], + odo: ["km's/ hours", "kms/ hours", "km/hours", "odo_reading", "odometer", "hour_meter", "hour meter"], + litres: ["litres pumped", "litres", "liters"], + site: ["current site", "site", "current_site"], + consumption: ["latest consumption", "latest_consumption", "consumption"], +}; + +/** Normalise a header for fuzzy matching. */ +export function normHeader(s: unknown): string { + return typeof s === "string" + ? s.replace(/\s+/g, " ").trim().toLowerCase() + : ""; +} + +/** + * Resolve the original header text for a logical column key. + * Returns the EXACT header string the spreadsheet used (so the row + * lookup uses the same key the source file provided), or null if no + * alias matched. + */ +export function resolveColumn( + headers: string[], + key: keyof typeof PLANT_COL_ALIASES | keyof typeof FUEL_COL_ALIASES, + aliasMap: Record = PLANT_COL_ALIASES as Record, +): string | null { + const target = aliasMap[key].map(normHeader); + for (const h of headers) { + if (target.includes(normHeader(h))) return h; + } + return null; +} + +/** + * Parse the date column into an ISO `YYYY-MM-DD` string. + * Primary: `DD/MM/YYYY`. Falls back to `YYYY-MM-DD` and Excel serials. + */ +export function parseDate(input: unknown): string | null { + if (input === null || input === undefined) return null; + if (typeof input === "number" && Number.isFinite(input) && input > 25569) { + const d = new Date(Math.round((input - 25569) * 86400 * 1000)); + if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10); + } + const s = String(input).trim(); + if (!s) return null; + let m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(s); + if (m) { + const dd = Number(m[1]); + const mm = Number(m[2]); + const yyyy = Number(m[3]); + if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) { + const iso = new Date(Date.UTC(yyyy, mm - 1, dd)); + if (!Number.isNaN(iso.getTime())) return iso.toISOString().slice(0, 10); + } + return null; + } + m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s); + if (m) { + const iso = new Date( + Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])), + ); + if (!Number.isNaN(iso.getTime())) return iso.toISOString().slice(0, 10); + } + return null; +} + +/** Coerce a cell value to a finite number, or null if empty / non-numeric. */ +export function toNumber(input: unknown): number | null { + if (input === null || input === undefined) return null; + if (typeof input === "number") return Number.isFinite(input) ? input : null; + if (typeof input === "string") { + const trimmed = input.trim(); + if (!trimmed) return null; + const n = Number(trimmed); + return Number.isFinite(n) ? n : null; + } + return null; +} + +/** + * Parse a time cell into `HH:MM` (24h). Returns null on malformed input. + * Single-digit hours are normalised to two digits (`6:00` → `06:00`). + */ +export function parseTime(input: unknown): string | null { + if (input === null || input === undefined) return null; + const s = String(input).trim(); + if (!s) return null; + const m = /^(\d{1,2}):(\d{2})$/.exec(s); + if (!m) return null; + const hh = Number(m[1]); + const mm = Number(m[2]); + if (hh < 0 || hh > 23 || mm < 0 || mm > 59) return null; + return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`; +} \ No newline at end of file