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:
parent
87bb1790fe
commit
077dc35cf2
3 changed files with 177 additions and 91 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
34
src/lib/import-parse.test.ts
Normal file
34
src/lib/import-parse.test.ts
Normal 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
124
src/lib/import-parse.ts
Normal 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")}`;
|
||||
}
|
||||
Loading…
Reference in a new issue