April 11, 2026

Every Time Format At Once: A Converter That Stops the Unix-Seconds-or-Milliseconds Dance

Every Time Format At Once: A Converter That Stops the Unix-Seconds-or-Milliseconds...

By SEN LLC (@sendotltd) • 4 min read
Every Time Format At Once: A Converter That Stops the Unix-Seconds-or-Milliseconds Dance

Every Time Format At Once: A Converter That Stops the Unix-Seconds-or-Milliseconds Dance

1721462400. Is that seconds or milliseconds? UTC or local? Which Tuesday was that, in my timezone? I've wasted approximately a month of my career on this question. So I built a box where you paste anything and every format shows up at the same time.

Every developer eventually sees a number like 1721462400 in a log. Then comes the micro-calculation: check the digit count to guess seconds vs. milliseconds, punch it into a browser console with new Date(...), stare at the local time, try to work out what that is in Pacific. I wrote a tool that collapses the whole sequence to a single paste.

🔗 Live demo: https://sen.ltd/portfolio/time-converter/
📦 GitHub: https://github.com/sen-ltd/time-converter

Screenshot

One input, nine output rows:

  • Unix seconds
  • Unix milliseconds
  • ISO 8601 (UTC)
  • RFC 2822 (GMT)
  • JST (Asia/Tokyo, DST-aware)
  • PST (America/Los_Angeles, DST-aware)
  • EST (America/New_York, DST-aware)
  • CET (Europe/Berlin, DST-aware)
  • A human relative time ("5 minutes ago", "3 days from now")

Vanilla JS, zero deps, no build. Timezones go through Intl.DateTimeFormat, so DST transitions are automatic.

Auto-detecting the input format

People paste five or six different time representations into this input, and the tool has to figure out which one without asking. The key insight: digit count alone gets you seconds vs. milliseconds vs. microseconds with zero ambiguity.

export function parseTime(input) {
  const s = input.trim()

  // Unix milliseconds (13 digits)
  if (/^-?\d{13}$/.test(s)) return { ok: true, ms: Number(s), format: 'unix-ms' }
  // Unix seconds (10 digits)
  if (/^-?\d{10}$/.test(s)) return { ok: true, ms: Number(s) * 1000, format: 'unix-s' }
  // Unix microseconds (16 digits) — common in tracing
  if (/^-?\d{16}$/.test(s)) return { ok: true, ms: Math.round(Number(s) / 1000), format: 'unix-us' }

  // ISO 8601
  const isoMatch = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:?\d{2})?$/.exec(s)
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The digit-count trick works because Unix seconds have been ten digits since September 9, 2001 and won't hit eleven digits until 2286. Nobody in this industry is pasting times outside that window. Sixteen digits = microseconds is a little unusual, but tracing tools like TraceEvent frequently use microsecond timestamps, so it's worth handling.

The "ISO 8601 without a timezone" mess

If the input is 2024-07-20T08:00:00 with no timezone, what does it mean? RFC 3339 technically says "local time." But in the real world, 99% of timezone-less ISO strings come from log output and the author meant UTC.

JavaScript's built-in behavior is worse than either interpretation:

new Date('2024-07-20T08:00:00').getTime()
// Returns local-time-interpretation. Same string, different answer
// depending on where your machine is. Nightmare fuel.
Enter fullscreen mode Exit fullscreen mode

So the tool explicitly re-parses with Date.UTC() when no timezone is present:

if (!tz) {
  const t = Date.UTC(
    Number(Y), Number(M) - 1, Number(D),
    Number(h), Number(m), Number(sec), Number(msPadded)
  )
  return { ok: true, ms: t, format: 'iso (UTC assumed)' }
}
Enter fullscreen mode Exit fullscreen mode

And critically, the format label it returns says iso (UTC assumed) — the UI shows that explicitly so the user knows an assumption was made. Silent assumptions in datetime code are a source of endless bugs.

Outsourcing DST to Intl.DateTimeFormat

JST is a permanent UTC+9 so it's easy. PST, EST, and CET all observe daylight saving, and the transition dates change every few years as various legislatures debate abolishing it. Computing this manually is a losing game.

Happily, Intl.DateTimeFormat with the timeZone option does the entire thing for free:

export function formatInTz(ms, tz) {
  const fmt = new Intl.DateTimeFormat('en-GB', {
    timeZone: tz,           // 'America/Los_Angeles', etc.
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit',
    hourCycle: 'h23',
    timeZoneName: 'short',
  })
  const parts = fmt.formatToParts(new Date(ms))
  const get = (t) => parts.find((p) => p.type === t)?.value ?? ''
  return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')} ${get('timeZoneName')}`
}
Enter fullscreen mode Exit fullscreen mode

I use formatToParts rather than format because I need fixed output order regardless of locale conventions. en-GB was chosen for its h23 (00-23) affinity and sane default ordering; it's a pragmatic hack, not a principled choice.

The best part: timeZoneName: 'short' returns PST in February and PDT in July, automatically. Zero logic on my end:

formatInTz(Date.parse('2024-02-01T00:00:00Z'), 'America/Los_Angeles')
// '2024-02-01 00:00:00 PST'

formatInTz(Date.parse('2024-07-01T00:00:00Z'), 'America/Los_Angeles')
// '2024-07-01 00:00:00 PDT'  (D for daylight)
Enter fullscreen mode Exit fullscreen mode

The DST transition crossovers just work.

Relative time: biggest unit first wins

The "5 minutes ago" line is built by walking units in descending order and picking the first one with a non-zero count:

export function humanRelative(ms, nowMs = Date.now()) {
  const diff = Math.floor((ms - nowMs) / 1000)
  const absS = Math.abs(diff)
  const units = [
    { unit: 'year',   seconds: 365 * 24 * 3600 },
    { unit: 'month',  seconds: 30 * 24 * 3600 },
    { unit: 'week',   seconds: 7 * 24 * 3600 },
    { unit: 'day',    seconds: 24 * 3600 },
    { unit: 'hour',   seconds: 3600 },
    { unit: 'minute', seconds: 60 },
    { unit: 'second', seconds: 1 },
  ]
  for (const { unit, seconds } of units) {
    if (absS >= seconds) {
      const n = Math.floor(absS / seconds)
      const suffix = diff >= 0 ? 'from now' : 'ago'
      return `${n} ${unit}${n === 1 ? '' : 's'} ${suffix}`
    }
  }
  return 'just now'
}
Enter fullscreen mode Exit fullscreen mode

Intl.RelativeTimeFormat exists and is great for many cases, but I wanted full control over the unit buckets (e.g., flooring anything under a second to "just now"), so I rolled my own. "1 month" meaning "30 days" is technically wrong but reads correctly for glance-based UI.

Tests

14 cases on node --test. Highlights:

  • Digit-count detection for seconds / ms / microseconds
  • ISO 8601 with and without timezone
  • RFC 2822
  • Weird inputs that fall through to native Date parser
  • DST boundary day for formatInTz
  • Past / future / right-now for relative time
npm test
Enter fullscreen mode Exit fullscreen mode

Intl tests rely on Node 18+'s built-in ICU data, so they pass out of the box on modern Node.

Series

This is entry #7 in my 100+ public portfolio series, and the sister tool to Cron TZ Viewer (entry #1) — same Intl-powered, zero-dependency philosophy, but for single timestamps rather than repeating patterns.

Feedback welcome.

Ready to build your next web project? Let's work together.