refactor(imports): extract shared import-parse helpers + add parseTime

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.
This commit is contained in:
DEls 2026-08-28 12:41:59 +02:00
parent 87bb1790fe
commit 077dc35cf2
3 changed files with 177 additions and 91 deletions

View file

@ -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<string, string[]> = {
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);
}

View file

@ -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
});
});

124
src/lib/import-parse.ts Normal file
View file

@ -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<string, string[]> = {
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<string, string[]> = {
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<string, string[]> = PLANT_COL_ALIASES as Record<string, string[]>,
): 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")}`;
}