A spending chart can look perfectly reasonable while summing the wrong CSV column. I'll walk through the browser-side import pipeline I use to separate correctly parsed fields from values that are actually safe to summarize.
The implementation accepts a ride export, identifies date and amount columns, builds normalized records, and derives summaries and charts from those records. Three details are worth separating from the visualization layer: quoted CSV fields, exact-first header matching, and currencies that must never be treated as interchangeable numbers.
A Comma Is Not Always a Field Separator
Splitting every line on commas works until an address contains a comma. Splitting the whole file into lines first also breaks when a quoted field contains a newline.
The parser in this implementation walks the text character by character. It tracks the current field, the current row, and whether it is inside a quoted field. This is the quoted-field branch:
if (inQuotes) { if (char === '"') { if (text[i + 1] === '"') { field += '"'; i += 2; } else { inQuotes = false; i += 1; } } else { field += char; i += 1; } continue; } Two consecutive quotes inside a quoted field become one literal quote. A single closing quote exits quoted mode. Everything else, including a comma or newline, is appended to the field rather than interpreted as a boundary.
Outside quoted mode, commas finish fields and newlines finish rows. The loop skips carriage returns outside quotes, supporting the usual CRLF row endings. It also retains the final row when the file does not end with a newline.
I find the state-machine explanation more useful than describing CSV as “comma-separated text.” The separator only has meaning in the right state. That same observation turns up in log parsers, command-line tokenizers, and almost any format with escaping.
This parser is intentionally small, though. It does not expose a detailed malformed-CSV diagnostic, and an unmatched quote is not reported as a specific syntax error. Supporting ordinary exports is not the same as implementing a strict validator for every possible CSV dialect.
Try Exact Header Matches Before Broad Keywords
Real exports use inconsistent naming. The implementation normalizes headers by trimming whitespace, lowercasing, replacing underscores and hyphens with spaces, and collapsing repeated spaces.
After normalization, the column search makes two passes:
function findColumnIndex(headers, candidates) { const normalized = headers.map(normalizeHeader); for (const candidate of candidates) { const idx = normalized.indexOf(candidate); if (idx !== -1) return idx; } for (const candidate of candidates) { const idx = normalized.findIndex((h) => h.includes(candidate)); if (idx !== -1) return idx; } return -1; } The amount candidates put original fare local before broader names such as fare, amount, and total. That matters for an export containing both original_fare_local and a field such as is_fare_split.
A substring-only search for “fare” could select a flag column just because it appears earlier in the file. Exact matching across the candidate list first gives a known real amount column priority over that weak match.
The fallback still helps with unfamiliar exports, but it remains a heuristic. A header containing a generic word such as “time” is not proof that it represents the trip timestamp you intended.
Source detection is separate from column selection. The UI can show an inferred provider and let the user override its badge, but that choice does not rewrite the date and amount candidate lists or reparse the file. I would not describe that control as a provider-specific parsing mode.
Normalize Records Before Summarizing Them
Each accepted record stores a date, an amount, a currency, and derived weekday and hour values. Rows producing an invalid Date or NaN amount are skipped. When a status column is available, a case-insensitive substring check skips statuses containing the English fragments cancel, reject, or refund. This is not a whitelist of completed trips, and it does not recognize equivalent statuses in every language.
That means the result is an analysis of the retained ride records, not automatically a reconciliation of every transaction in an account statement. Excluding refunds may be useful for examining completed trips, but it changes the accounting question.
Currency handling is another boundary. The import collects distinct nonempty currency codes and enables a currency selector when more than one exists. The filtered-records computation contains this check:
if (multiCurrency.value && selectedCurrency.value) { return r.currency === selectedCurrency.value; } return true; This is filtering, not foreign-exchange conversion. One hundred USD and one hundred TWD should not become an unexplained total of two hundred. For correctly labeled records, selecting one currency gives the sums and average fare a consistent unit.
Missing labels are a hole in that guarantee. Empty currency fields do not count toward the distinct-code total. With only USD and unlabeled records, the multi-currency check stays off and both are summed; with USD, TWD, and unlabeled records, selecting either code excludes the unlabeled rows. I would verify those missing labels before trusting a total, rather than assume the importer inferred their currency.
Monthly aggregation then operates on those filtered records. It groups by a year-and-month key, accumulates spend and ride count, sorts the keys, and divides each month's spend by its ride count.
There is a useful limitation in the implementation: it does not create missing months between observed months. A month with no retained rides is absent, rather than represented as an explicit zero. A line chart connecting surrounding months should not be mistaken for a complete monthly time series.
Parsing Successfully Does Not Prove the Numbers Are Right
The amount parser strips characters other than digits, decimal points, and minus signs, then calls parseFloat. It also recognizes parentheses as a negative amount.
That suits inputs such as 1,234.56, but it is not locale-aware. A value written as 1.234,56 becomes 1.23456, not 1,234.56. The CSV parser can correctly preserve the field while the amount parser still assigns the wrong numeric meaning.
Dates have a similar boundary: the implementation uses the built-in Date constructor and browser-local weekday and hour accessors. A valid timestamp is not proof of a valid calendar entry: 2026-02-30T12:00:00 rolls into March instead of being rejected. Ambiguous date strings and timestamps with offsets need careful checking before interpreting a late-night spending pattern; month grouping also follows the browser's local timezone, not necessarily the trip's original timezone.
File contents are read with FileReader and processed in this client-side flow; they are not sent to a ride-analysis backend by this code. That is narrower than claiming the entire website is offline or makes no unrelated network requests.
For testing, I would begin with this tiny fixture:
trip_date,is_fare_split,original_fare_local,currency,status,address 2026-01-15T12:00:00,false,"1,234.56",USD,completed,"Main St, Apt ""B""" 2026-03-15T12:00:00,false,100,TWD,completed,Station not-a-date,false,25,USD,completed,Station 2026-01-20T12:00:00,false,10,USD,refunded,Station The expected result is two retained rides: USD 1,234.56 and TWD 100, viewed separately, with the invalid date and refunded row excluded. The first address should become Main St, Apt "B" without shifting the amount column. February should not appear as a generated zero month. These checks exercise field boundaries, header priority, row exclusion, and aggregation together. I would add separate cases for quoted newlines, missing currency labels, and locale-specific amounts; the fixture above does not cover those. Expected totals are more valuable than a visually polished chart built from an unverified import.
I turned that import-and-summary pipeline into a small free tool: Ride History Visualizer.


