How Convert Timestamp is built
Convert Timestamp turns a Unix timestamp into a date and back; this post is about the small conversion engine underneath it — how it tells seconds from milliseconds, how it handles timezones without a selector, and the native Date behavior it deliberately leans on.
Tech used
Digit-count heuristic for seconds vs. milliseconds
A raw Unix timestamp is just an integer, and the same field has to accept both 1700000000 (seconds) and 1700000000000 (milliseconds) with no unit picker. parseTimestampInput in src/utils/timestampEngine.ts decides which by counting digits in the trimmed input: 10 digits or fewer is read as seconds, 11 to 13 as milliseconds, and a leading - (for a pre-1970 timestamp) is stripped before counting so a negative value isn’t penalized a digit. 14 or more digits is rejected outright with a too-many-digits error rather than guessed — the code comment is explicit that this is “outside the confirmed range,” a deliberate boundary rather than an oversight.
The 10/11-digit split is what makes the heuristic reliable rather than a coin flip: a 10-digit second count tops out in the year 2286, and the same instant in milliseconds always has exactly three more digits, so real-world timestamps never land in a genuinely ambiguous zone between the two lengths. The unit tests pin the boundary directly — 1234567890 (10 digits) resolves as seconds, 12345678901 (11 digits) as milliseconds, and both spellings of the reference instant 1700000000 / 1700000000000 resolve to the identical epoch millisecond value.
One more edge the code checks explicitly rather than assumes: a 13-digit millisecond value and a 10-digit second value multiplied by 1000 both stay far inside the ±8.64×10¹⁵ ms range that Date can represent, so the digit-count cap alone is enough to guarantee the result is a representable date — no separate overflow check is needed after parsing.
Native Date as the only conversion primitive
There is no date library here — package.json lists only astro and preact as dependencies, nothing date-related. Every function in timestampEngine.ts is a pure function built on new Date(ms) plus its getters: getFullYear/getUTCFullYear, getMonth/getUTCMonth, and so on, selected per call by a 'local' | 'utc' flag rather than duplicating the formatting logic. Parsing works the same way in reverse — a Unix timestamp is converted to epoch milliseconds by hand (value * 1000 for seconds, passed through unchanged for milliseconds), while a datetime-local value is handed straight to new Date(raw) and read back with .getTime().
Local time and UTC shown side by side, not a timezone selector
The tool has no timezone selector at all — the design instead computes both a local rendering and a UTC rendering from the same epoch millisecond value and shows them together, so there is nothing to select and therefore nothing to misread. formatLocal and formatUtc are the same formatParts function called with different accessors; both run off one ms value, so they can’t drift out of sync with each other.
Labeling “local” needs the actual offset, not just the formatted numbers: localZoneLabel reads Date.prototype.getTimezoneOffset(), which returns the offset as minutes UTC is ahead of local time — the inverse sign of how a UTC+9 label reads — so the code negates it before formatting. It then appends the IANA zone name (e.g. Asia/Tokyo) from Intl.DateTimeFormat().resolvedOptions().timeZone when the runtime exposes one, wrapped in a try/catch that falls back to the bare offset label if that call fails for any reason.
The datetime-local field’s local-time parsing, used deliberately
parseDatetimeLocalInput passes the <input type="datetime-local"> value straight to new Date(raw) with no manual field extraction, and a comment in the source spells out why that’s safe: per the ECMA-262 Date Time String Format, a date-time string with a T separator and no timezone offset is parsed in the local timezone — unlike a date-only string ("2024-01-15" alone), which Date parses as UTC. That asymmetry is a well-known trap when it’s hit by accident; here it’s the exact behavior the code depends on, since a datetime-local value never carries seconds and the intent is always “this is what the visitor’s local clock read.”
Relative time via Intl.RelativeTimeFormat
The “3 days ago” / “in 2 hours” string is native too: relativeTimeFrom walks a fixed table of units from year down to second, each expressed as a flat millisecond count (year = 365 days, month = 30 days — a calendar approximation, not a calendar-aware calculation), and picks the largest unit whose millisecond size the absolute difference reaches, formatting it with Intl.RelativeTimeFormat(locale, { numeric: 'auto' }). Anything under a minute falls through to seconds, which is also what turns a zero difference into Intl’s own "now" string rather than "0 seconds ago".
Implementation & operational notes
Rounding to seconds rounds, it doesn’t truncate. toEpochSeconds uses Math.round(ms / 1000), not Math.floor. The unit tests check this at the boundary: 1,700,000,000,499 ms rounds down to 1,700,000,000 s, and 1,700,000,000,500 ms rounds up to 1,700,000,001 s. It matters for round-tripping — mirroring a datetime-local value with millisecond precision back into the seconds field goes through this rounding, not truncation, so the nearest whole second is shown rather than one that’s silently biased earlier.
The two input fields sync one-way per edit, with no feedback loop. Whichever field the visitor last typed into becomes the source of truth for that render (source: 'timestamp' | 'datetime' | null); once it parses, an effect writes the other field to match. That mirror write is safe from looping because a programmatic setState never re-fires the target input’s own input event, so it can’t flip source back and re-trigger itself. This lives in ConvertTimestampTool.tsx, one layer above the pure engine functions, which stay stateless.
Errors are typed results, not exceptions. Every parse function returns { ok: true, value } | { ok: false, error: { code } } rather than throwing, with a closed set of error codes (empty, not-integer, too-many-digits, invalid-datetime). The engine has no locale, so it hands back a stable code and lets the UI layer resolve it to a translated message — the same code never has to guess at user-facing text.
Deliberately out of scope. Per the README, this tool does not browse a timezone database or parse cron expressions — the conversion is scoped to one instant at a time, shown in local time, UTC, ISO 8601, and as a relative phrase, and no further.