Data import
A TypeScript CSV Importer That Refuses to Guess Your Fuel History
Detecting CSV conventions without silently changing dates, units or full-tank flags. A TypeScript importer with a review step before writing.
Updated
On this page
03/09/2026 is a valid date in two different histories.
If I import it as March 9 when the user meant September 3, the parser does not throw. The database accepts the record. The timeline looks tidy. Every report built on top of that date is now wrong.
This is the problem I wanted the CSV importer in OdoKeep to handle: mistakes that produce valid data.
OdoKeep is a vehicle logbook built with React Native, Expo, and TypeScript. Someone trying it may already have years of fuel entries in another app or a spreadsheet. Making them leave that history behind creates a substantial cost before they have even decided whether they like the new app.
Reading a CSV is one part of lowering that cost. Preserving what its numbers mean is the larger part.
Inspect, build, then write
The implementation lives in lib/csv-import.ts and has two distinct outputs. Inspection produces the parsed table, suggested column mappings, detected source, conventions, and unresolved questions. Building records produces ordinary VehicleRecordDraft objects and a list of skipped rows with reasons. Writing is a separate step through the existing garage import path.
That separation gives the user a chance to see a row from their own file before any record is added.
Detect the delimiter by parsing the file
Delimiter detection is the first misleadingly simple problem. A Portuguese spreadsheet can use semicolons between columns and commas in almost every numeric value:
Data;Odometro;Litros;Preco/l;Valor total
03/09/2026;142350;31,93;1,899;60,64
04/09/2026;142890;28,5;1,879;53,55
Counting commas and semicolons is not a reliable way to decide which character separates fields. The commas might be part of the numbers. Quoted notes can contain either character too.
The importer tries comma, semicolon, tab, and pipe by actually parsing the text with each candidate. It scores how consistently the rows match the header width, with a small contribution from column count. A delimiter that never splits the file into at least two columns is not useful evidence.
The parser itself is a character loop with quote state. It supports quoted delimiters, line breaks inside quoted fields, doubled quotes, a byte order mark, and the newline forms the importer expects. Splitting the input into lines before parsing would already have destroyed a multiline note.
These are concessions to files people keep and edit, rather than a claim to recognise every malformed CSV variant. The next stages still have to establish whether the resulting table can be used.
Use file-wide evidence for numbers and dates
Numbers need a file-wide decision. 1.234 can be a decimal or a grouped integer. A single token often cannot settle the convention, but the other mapped numeric columns may contain useful evidence.
The detector looks across quantity, unit price, total cost, and odometer values. A value containing both separators provides strong evidence about which convention is in use. Otherwise, a separator followed by a number of digits other than three can distinguish a decimal from ordinary thousands grouping.
Values that remain ambiguous contribute no evidence. If nothing in the mapped data settles the convention and no recognized preset supplies a default, the inspection returns null and the screen asks.
Date order follows a similar idea. A year-first date identifies that shape. For numeric dates with the year at the end, a component greater than twelve can establish which side is the day. If all dates are on or before the twelfth, the column may have no answer.
That is a legitimate result. A user's locale is not proof of the conventions used by a file produced elsewhere.
Let evidence outrank a preset
The importer has Fuelly and Drivvo presets, but they sit below evidence from the file. The relevant inspection code is compact:
const named = unitFromHeaders(table.headers, mapping);
return {
table,
mapping,
source,
decimal: detectDecimalStyle(numeric) ?? source?.decimal ?? null,
dateOrder: detectDateOrder(column("date")) ?? source?.dateOrder ?? null,
odometerUnit: named.odometerUnit ?? source?.odometerUnit ?? null,
// Other inspection fields are omitted here.
};
The same precedence is used for supported quantity units: a unit named by the file outranks the preset's default.
A Fuelly-shaped file may have been produced by a metric account or reopened and saved by a European spreadsheet application. Recognizing the source is useful for proposing a mapping. It does not give the source preset permission to contradict the contents.
Some mappings need domain knowledge that generic string matching cannot supply. The Fuelly preset distinguishes odometer from miles, and fuelup date from date added. Binding the distance since the previous fill as the odometer gives a series of plausible numbers with the wrong meaning. Binding the entry date as the purchase date can move years of history to the afternoon someone typed it in.
Its price binding is a unit price, so the builder can derive a missing total from quantity multiplied by price. For Drivvo-shaped data, unit-price columns are considered before total-cost columns so that similar headers do not claim the same field incorrectly.
The exact presets have a limit worth being clear about. They were built from documentation and existing importers, rather than exports made by hand from real Fuelly and Drivvo accounts during this work. Some headers and defaults remain assumptions, especially the exact Drivvo header spellings. The fixtures test how the implementation interprets those shapes; they do not certify every version or locale those vendors might export.
That uncertainty is one reason the mapping remains visible and editable. A preset error should be something the user can correct before it becomes history.
Unknown is a real tank state
Full-tank information creates another quiet failure. Consumption calculations need to distinguish a full fill, a partial fill, and a fill whose status is unknown.
Some source shapes express the flag as "partial fuel-up", so the preset inverts a nonblank value. But a blank cell stays unknown even on that inverted column:
isFullTank: rawFull
? options.fullTankInverted
? !isAffirmative(rawFull)
: isAffirmative(rawFull)
: undefined
An empty partial flag is not automatically proof of a full tank. An absent full-tank column is not proof that every fill was partial. Both shortcuts would change the inputs to consumption calculations without a visible parsing failure.
Validate the calendar day and the row
Date storage has a second trap after the order is settled. JavaScript consumers interpret a date-only ISO string as UTC midnight. Display that instant in a timezone west of UTC and the date can become the previous evening.
The importer first validates the calendar day explicitly, including the actual length of its month. February 30 does not roll forward into March. It then writes local midnight with the device's timezone offset, matching the convention used by the app's manual forms. The user's chosen September 3 should remain September 3 in the timeline and monthly totals.
The row builder rejects negative values and values above the app's shared ceilings before the write stage. This makes a mistyped odometer or a refund-shaped row an identifiable skipped row instead of something that can cause a chunk of otherwise valid records to fail together.
A row with a date and no usable odometer, quantity, or cost is also skipped. Importing an entry that says nothing happened would make the history longer without making it more useful.
The result reports imported drafts separately from skipped rows. The user can correct the source file or decide whether to proceed with what was read. Once confirmed, the drafts use the existing import writer and offline queue. The CSV reader does not create another persistence model to maintain.
Probe without writing
For development, there is a small csv:probe script that reuses inspection and record building without writing. It prints the delimiter, conventions, column bindings, example records, and refused rows. When it uses provisional defaults for a diagnostic run, it labels them as assumptions. That makes a candidate fixture inspectable without launching the app or opening an account.
The tests include semicolon and decimal-comma data, both preset shapes, ambiguous conventions, quoted multiline notes, invalid dates, negative values, explicit units that override a preset, and unknown tank flags. The mixed-format fixture is useful because these problems often arrive together in one file.
There is no guarantee here that an arbitrary export can be imported unattended. The implementation makes suggestions, validates what it can, and gives the remaining decisions a place in the UI. For someone moving a long-lived logbook, a visible question about units is a small cost compared with a silent reinterpretation of every entry.
If you already keep fuel history in a CSV, that is the workflow I am building into OdoKeep: inspect the file, check the mapping and units, then carry the usable records into the same history as new entries. You can try it in the iOS app.