The hard part of batch date conversion isn't formatting — it's deciding what `01/02/2024` means

javascript dev.to

I used to think a bulk date converter was basically a dropdown wrapped around a date library. Paste a bunch of rows, pick YYYY-MM-DD, done. Then you look at real exports from spreadsheets, CRMs, logs, and old internal tools and realize the problem isn't "formatting" at all.

It's triage.

Some rows are obvious. Some are malformed. Some have month names. Some came from a CSV with five unrelated columns. And then there's the classic cursed input: 01/02/2024, which is either January 2 or February 1 depending on who produced the file. The Vue component behind this tool is interesting because it doesn't pretend that ambiguity goes away if you call the right parser. It models that ambiguity explicitly.

It starts by assuming uploaded files are messy, not clean

One thing I liked in the source is that it doesn't treat file input as a single happy path. If you upload a TXT file, it works line by line. If the upload looks CSV-ish, it switches into a tiny parser and then tries to figure out which column is actually the date column.

The CSV split logic is manual instead of using a naive line.split(","), which matters because quoted commas are a real thing in exports:

const splitCsvLine = (line) => {
  const result = [];
  let cur = "";
  let inQuotes = false;
  for (let i = 0; i < line.length; i++) {
    const ch = line[i];
    if (ch === '"') {
      if (inQuotes && line[i + 1] === '"') {
        cur += '"';
        i++;
      } else {
        inQuotes = !inQuotes;
      }
    } else if (ch === "," && !inQuotes) {
      result.push(cur);
      cur = "";
    } else {
      cur += ch;
    }
  }
  result.push(cur);
  return result.map((s) => s.trim());
};
Enter fullscreen mode Exit fullscreen mode

After that, it doesn't ask the user to map columns immediately. It scores each column by counting how many cells look like dates, then auto-selects the best candidate:

for (let c = 0; c < maxCols; c++) {
  const count = dataRows.filter((r) => isLikelyDateCell(r[c])).length;
  columns.push({ index: c, header: headerRow ? headerRow[c] : "" });
  if (count > bestCount) {
    bestCount = count;
    bestIdx = c;
  }
}
Enter fullscreen mode Exit fullscreen mode

That's a small detail, but it matches how people actually use these tools. A lot of the time the date data is buried inside a broader export, and the nicest UX isn't magical parsing of the entire file — it's auto-picking the most plausible date column while still letting me override it.

There's also a quiet heuristic here I appreciate: if the first CSV row doesn't look like dates, the component treats it as a header row. Again, that feels very grounded in real data cleanup work instead of demo data.

The parser is layered, explicit, and much less trusting than new Date()

The core function is analyzeLine, and the good part is that it doesn't just throw every string at JavaScript date parsing and hope the runtime locale does something sensible. It walks through a few explicit cases.

First, strings that begin with a four-digit year get treated as ISO-like input, including slash and dot variants:

if (/^\d{4}[-/.]\d{1,2}[-/.]\d{1,2}/.test(s)) {
  const isoLike = s.replace(/\./g, "-").replace(/\//g, "-");
  let dt = DateTime.fromISO(isoLike);
  if (!dt.isValid) dt = DateTime.fromISO(s);
  if (dt.isValid) {
    return {
      kind: "final",
      dt,
      detected: "ISO 8601 / YYYY-MM-DD",
      ambiguous: false,
      order: null,
      hasTime: /\d{1,2}:\d{2}/.test(s),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Then it handles month-name formats using regex plus a hardcoded MONTH_MAP, and only after that falls back to purely numeric forms. That order matters. It means March 3, 2024 isn't left up to environment-dependent parsing; the code extracts the parts and builds a Luxon DateTime directly.

The numeric branch is where the real cleanup logic lives:

const pNum = /^(\d{1,4})[/\-.\s](\d{1,4})[/\-.\s](\d{2,4})(?:[T\s,]+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/;
const nm = s.match(pNum);
if (!nm) return { kind: "error", reason: "unrecognized" };

const g1 = nm[1], g2 = nm[2], g3 = nm[3];
const n1 = parseInt(g1, 10), n2 = parseInt(g2, 10), n3 = parseInt(g3, 10);
Enter fullscreen mode Exit fullscreen mode

From there, the code checks whether the year is first or last, whether one of the remaining numbers is definitely too large to be a month, and whether the whole thing is genuinely valid once Luxon constructs a date object.

A subtle but very real implementation choice: two-digit years are normalized with a fixed cutoff. If the year string is two digits, 00 to 49 becomes 2000-2049, and 50 to 99 becomes 1950-1999. That's not "universal truth" — it's a product choice. I actually like seeing that choice made in code instead of buried inside a library default.

Ambiguous dates are a first-class state, not a hidden guess

This is the part that makes the tool feel honest.

If a numeric row can be proven to be DMY or MDY — say 31/12/2023 or 12/31/2023 — it gets parsed as a final answer immediately. But if both the day-ish and month-ish numbers are <= 12, the parser does not fake certainty. It returns a pending result and defers the decision.

The batch processor then counts unambiguous DMY and MDY rows and uses that as a majority-vote guess for the ambiguous ones:

let dmyCount = 0, mdyCount = 0;
analyzed.forEach(({ a }) => {
  if (a.kind === "final" && a.order === "DMY") dmyCount++;
  if (a.kind === "final" && a.order === "MDY") mdyCount++;
});
let globalGuess = null;
if (dmyCount > mdyCount) globalGuess = "DMY";
else if (mdyCount > dmyCount) globalGuess = "MDY";
Enter fullscreen mode Exit fullscreen mode

And then, for each pending row:

if (forcedOrder === "dmy") {
  day = a.remA; month = a.remB; order = "DMY"; flagAmbiguous = false;
} else if (forcedOrder === "mdy") {
  month = a.remA; day = a.remB; order = "MDY"; flagAmbiguous = false;
} else if (globalGuess === "DMY") {
  day = a.remA; month = a.remB; order = "DMY"; flagAmbiguous = true;
} else if (globalGuess === "MDY") {
  month = a.remA; day = a.remB; order = "MDY"; flagAmbiguous = true;
} else {
  day = a.remA; month = a.remB; order = "DMY"; flagAmbiguous = true;
}
Enter fullscreen mode Exit fullscreen mode

That last else is the important bit. If the batch has no reliable signal, the component still proceeds, but it defaults ambiguous rows to DMY and marks them as ambiguous. That's exactly the kind of tradeoff I want a bulk-cleanup tool to make. Keep the workflow moving, but don't pretend an unknowable row was knowable.

The UI follows through on that model too: the summary distinguishes successful rows, ambiguous rows, and outright errors, and there's a filter for showing only rows that need review. That's much better than burying the risky rows inside a wall of "success."

Output formatting is boring on purpose, which is the right call

Once the parser has turned a row into a real DateTime, the output stage is deliberately simple. The component has a small list of target formats, plus ISO 8601 and a custom Luxon format string:

const formatRowConverted = (row) => {
  if (row.status === "error" || !row.dt) return "";
  const opt = targetFormatOptions.find((o) => o.value === targetFormat.value) || targetFormatOptions[0];
  try {
    if (opt.isISO8601) {
      return row.hasTime ? row.dt.toISO({ suppressMilliseconds: true }) : row.dt.toISODate();
    }
    if (opt.isCustom) {
      return row.dt.toFormat(customFormat.value || "yyyy-MM-dd");
    }
    return row.dt.toFormat(opt.format);
  } catch (e) {
    return "#FORMAT_ERROR#";
  }
};
Enter fullscreen mode Exit fullscreen mode

I like two things here. First, time is only preserved in ISO output if the original row actually had time data, tracked earlier as hasTime. Second, custom formatting is handed to Luxon directly instead of inventing yet another token system.

The rest of the pipeline stays client-side too. Copying uses a temporary textarea and document.execCommand("copy"); downloads are generated with Blob and URL.createObjectURL; CSV export escapes quotes and prepends a UTF-8 BOM so Excel is less likely to mangle it. Even the results table is paginated at 100 rows per page, which is a very practical browser-side detail when you paste a few thousand lines.

None of that is glamorous, but it's the stuff that makes a utility feel dependable instead of half-finished.

Honest limitations and gotchas

A few source-level limitations are worth saying out loud.

The biggest one is month names: this parser only recognizes English month text. The regex is [A-Za-z]{3,9} and the mapping is a hardcoded English MONTH_MAP, so March 3, 2024 works, but localized month names won't.

The majority-vote ambiguity logic is also narrower than it first sounds. The global guess only counts rows that were definitively identified as DMY or MDY. ISO-style rows and month-name rows don't contribute because their order is null. So if your file is mostly 2024-01-05 plus a few ambiguous 01/02/2024 rows, there may be no signal at all, and the fallback becomes DMY-with-warning.

Two-digit years are another opinionated corner. The 49 cutoff is reasonable, but it still means 03/04/49 and 03/04/50 land in different centuries. That's fine as long as you know it, and worth double-checking if you're cleaning genuinely old data.

And one smaller implementation detail: CSV detection starts with rawLines.some((l) => l.includes(",")). In practice that's usually fine, but it does mean "contains a comma somewhere" is enough to push the upload into CSV handling. That's a heuristic, not a formal file-type detector.

I turned this into a small free tool: Batch Date Format Converter. I mostly use it for spreadsheet exports where I trust the raw values just enough to want a deterministic cleanup pass before importing them anywhere else.


Available in other languages

Source: dev.to

arrow_back Back to Tutorials