Engineering · Ruby on Rails · Databases

The Time Machine Problem

Getting dates and times right on the web is deceptively hard. Here is a complete, working recipe — built on Ruby on Rails 7.2 and MariaDB — for importing time, storing it in UTC, showing it in the user’s time zone, building the widgets, using Luxon, and not getting burned by daylight saving.

Chapter 00

The problem, in one paragraph

A user in Tokyo records an event at 2:30 in the afternoon. A user in New York opens the record three weeks later. If you store “14:30” and show “14:30,” you have shown them a time that is eleven hours off from when it actually happened — and if that “14:30” crossed a daylight-saving boundary, it may be a local time that did not exist at all.

Every date-and-time bug is a version of the same root mistake: confusing an instant (a point on the universal timeline) with a local reading of that instant (what a clock shows in one place). The fix is a single, strict contract that this whole article is built around:

Store instants in UTC. Display local time. Never store a wall-clock reading without its zone. Everything below — the schema, the widgets, the JavaScript, the charts — is just that contract, implemented.

The best part? You can watch a working implementation at the end, built with rails g generators against MariaDB. Let’s get into it.

Chapter 01

Time is not one thing

The same instant shown in many time zones
The same instant, read by clocks in different places. Your software must never lose the connection between them.

Three distinct ideas are routinely collapsed into one “time.” Keep them apart and nearly every time bug disappears.

ConceptWhat it isExampleWhere it lives
Instant (or epoch)A single point on the universal timeline, independent of place.2026-08-19 18:30:00 UTCYour database, your wire format.
Time zoneA named rule mapping instants ↔ local clock readings (offset + DST rules).America/New_York, Asia/TokyoIANA tz database; a user’s profile; the browser.
Local time (wall clock)What a clock displays for an instant in a given zone.2:30 PM EDTThe screen, the form, the report.

The IANA time zone database

Every zone is identified by a name like America/Chicago or Asia/Kolkata, not by a fixed offset. That matters because offsets change — for daylight saving and for outright political changes (India’s +05:30, Newfoundland’s −03:30, and the odd half-hour zones that break “offsets are whole hours” assumptions). The IANA tz database is the authoritative source; Ruby’s TZInfo and JavaScript’s Intl both consume it. Always name the zone; never store a bare offset like -05:00 as your truth.

UTC-12 UTC-8 UTC-4 UTC UTC+4 UTC+8 UTC+12 0% 25% 50% 75% 100% 12 27 8 4 3 9 8 7 4 6 12 30 6 4 2 Share of world population at each UTC offset (approx.) Eastern time zones (UTC−) in indigo · Western (UTC+) in teal. Numbers ≈ % of world population.
Most of the world clusters in a handful of UTC offsets. Notice the big lump around UTC+8 (China, ~30% of the population) and the Americas spread across UTC−2 to UTC−8. Your users are not where your server is.
Chapter 02

Storing time in MariaDB

Storing time in a database
One column, one rule: it holds a UTC instant.

First, decide what you are recording

Not every “time” is an instant. Ask which of these you have before you pick a column type:

DATETIME vs TIMESTAMP

MariaDB/MySQL gives you two tempting types. The trap with TIMESTAMP is that the server converts on read and write using its own session time zone — a hidden dependency on server config that has bitten teams for decades. DATETIME is inert: it stores and returns exactly the bytes you gave it. That predictability is exactly what you want if you commit to one rule:

Store DATETIME(6) values as UTC, always. The column is just a UTC instant in a human-readable shape. Rails makes this automatic — see below.

Use DATETIME(6) (six fractional digits) so sub-second precision survives round-trips. And because the app is single-writer and single-timezone (UTC), the inert column is both safe and easy to reason about.

The migration (real code from the demo)

ruby
# This migration generates the "records" table for the test CRUD.
#
# Columns:
#   name         - text
#   notes        - text
#   happened_at  - datetime, stored as UTC (the datetime-picker column)
#   + timestamps
class CreateRecords < ActiveRecord::Migration[7.2]
  def change
    create_table :records do |t|
      t.string :name
      t.text :notes
      t.datetime :happened_at, null: false, default: -> { "CURRENT_TIMESTAMP(6)" }

      t.timestamps
    end
  end
end
work/db/migrate/…_create_records.rb — the test CRUD table with the datetime column

Three details doing the heavy lifting:

The connection (real code)

yaml
# Rails 7.2 database configuration for MariaDB.
#
# Connection details are read from the environment so the same file works
# across any environment. The docker-compose service sets these:
#   DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD
#
# For LOCAL development (outside Docker) the defaults below point at
# localhost so `bin/rails db:prepare` works against a local MariaDB too.
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  host: <%= ENV.fetch("DB_HOST", "127.0.0.1") %>
  port: <%= ENV.fetch("DB_PORT", "3306") %>
  username: <%= ENV.fetch("DB_USERNAME", "rails") %>
  password: <%= ENV.fetch("DB_PASSWORD", "rails_secret") %>
  database: <%= ENV.fetch("DB_DATABASE", "work_development") %>
  # Store timestamps as UTC in the database.
  # (Rails already uses UTC internally; this documents the intent.)
  variables:
    sql_mode: "<%= ENV.fetch('DB_SQL_MODE', 'STRICT_TRANS_TABLES') %>"

development:
  <<: *default
  database: <%= ENV.fetch("DB_DATABASE", "work_development") %>

test:
  <<: *default
  database: <%= ENV.fetch("DB_DATABASE", "work_test") %>

production:
  <<: *default
  database: <%= ENV.fetch("DB_DATABASE", "work_production") %>
work/config/database.yml — MariaDB via mysql2, host from the container network
Note the absence of a timezone override. Rails defaults to treating all timestamps as UTC, which is what we want. The mysql2 adapter sends DATETIME strings verbatim, so a UTC value in goes and a UTC value out comes — no server-side conversion.

What Rails does for you

Rails loads every datetime column into a ActiveSupport::TimeWithZone object pinned to config.time_zone (UTC by default). record.happened_at.utc.iso8601 is your canonical string, and Time.zone.parse(...) is the one parser you should trust for incoming values. Never hand-build a time from user input with Date.new and a guessed offset — let the zone-aware APIs do the math.

User's zone (e.g. EDT) 14:30 local Picker JS → UTC 18:30 UTC MariaDB datetime 18:30 (UTC) Browser user's zone 02:30 (JST)
The whole data path. The only place a “local” value ever exists is the user’s browser and their screen.
Chapter 03

Presenting time in the user’s zone

The storage rule has a corollary on the way out: the server renders UTC, the browser renders local. The server has no reliable idea what the visitor’s zone is (a VPN changes the IP, not the browser clock), so the conversion belongs in the browser, where Intl knows the visitor’s OS zone.

The contract, end to end

  1. Server → HTML: embed the canonical UTC value as a data attribute, e.g. data-utc="2026-08-19T18:30:00Z".
  2. Browser: parse it, convert with toLocaleString() (which uses the visitor’s zone), and render it — labelled with the real zone abbreviation so it is never ambiguous.
  3. Hover/tooltip: show the stored UTC so a power user can always see the source of truth.

Always label the zone you display

The single cheapest way to prevent “is this my time or your time?” confusion is to print the abbreviation next to the time2:30 PM EDT, not 2:30 PM. Get the abbreviation from Intl.DateTimeFormat(..., {timeZoneName:"short"}).formatToParts(), and the IANA id from Intl.DateTimeFormat().resolvedOptions().timeZone.

The VPN problem. Your users cross time zones. A VPN changes their IP (and what the server’s geolocation thinks) but not the browser’s OS zone. So a user who moved to Tokyo but still has their laptop set to New York will keep seeing EDT. The fix is to detect the mismatch — compare the OS zone against the zone the device’s current wall clock actually implies — and show a warning. We do exactly this in the demo (the amber banner that fires when the two disagree).
Chapter 04

Building the date/time widgets

Building a date and time picker widget
Two native controls, one hidden UTC field, one widget.

We want a widget that (1) shows the value in the user’s local time, (2) submits a UTC timestamp, (3) prefills the stored value when editing, and (4) defaults to “now” in local time when creating. The cleanest way is one widget with two native inputs (a date box + a time box) plus a hidden field that carries the value actually sent to the server.

Why two native boxes beat a custom overlay

<input type="date"> and <input type="time"> give you the OS picker for free — accessible, mobile-friendly, no 40 KB of JS, no z-index wars with modals. The trick is that the visible boxes hold local date/time (which the browser already understands in the user’s zone), while a hidden, named input carries the UTC string that Rails stores. The JavaScript keeps the two in sync.

The server renders the widget (real helper)

ruby
module ApplicationHelper
  # Renders the local-timezone datetime picker widget for a form attribute.
  #
  #   local_datetime_picker(f, :happened_at)   # f = form builder
  #
  # Behaviour:
  #   * SHOWS the value in the USER'S LOCAL time zone (native <input type=date>
  #     / <input type=time> rendered by the browser in the visitor's local tz).
  #   * SUBMITS a UTC timestamp through a hidden input -> stored in the DB (UTC).
  #   * On EDIT: the hidden input is prefilled with the stored (UTC) value and the
  #     JS (local-datetime-picker.js) converts it into the user's local timezone
  #     for display in the visible controls.
  #   * On CREATE: the hidden input carries the current UTC "now" and the JS
  #     shows the current local date & time.
  #
  # The widget root is marked data-datetime-widget so the JS can attach.
  #
  # IMPORTANT: the whole widget is built as ONE string that is marked html_safe
  # at the very end, so the closing </div> and the inner <input> tags are emitted
  # verbatim (not HTML-escaped).

def local_datetime_picker(f, attribute, options = {})
    label_text = options.fetch(:label) { attribute.to_s.humanize }
    obj = f.object

    current = (obj && obj.respond_to?(attribute)) ? obj.public_send(attribute) : nil
    # .utc works on both Time and ActiveSupport::TimeWithZone.
    utc_iso = (current.present?) ? current.utc.iso8601.to_s : Time.now.utc.iso8601.to_s

    # Build the hidden input as its own safe fragment (value is HTML-escaped).
    hidden_safe = f.hidden_field(
      attribute,
      value: h(utc_iso),
      data: { dt_hidden: "", dt_initial: utc_iso }
    ).html_safe

    # One plain (unsafe) string for the static markup, concatenated with the
    # safe hidden fragment, then marked html_safe once at the end.
    markup =
      %(<div class="field datetime-widget" data-datetime-widget="">) +
      %(<label class="label" for="dt_#{attribute}">) + label_text.to_s +
      %(</label>) +
      %(<div class="dt-controls">) +
      %(<input type="date" class="dt-date" data-dt-date="">) +
      %(<input type="time" class="dt-time" data-dt-time="">) +
      %(</div>) +
      hidden_safe.to_s +
      %(<div class="dt-preview" data-dt-preview=""></div>) +
      %(</div>)

    markup.html_safe
  en
work/app/helpers/application_helper.rb — local_datetime_picker(f, :happened_at)

Two things to notice: the visible date/time inputs have no name (so they never submit their own values — only the hidden UTC input does), and the whole widget is built as one html_safe string so the closing </div> is emitted verbatim, not escaped. (Escaping it was a real bug we hit and fixed — it left the widget’s DOM malformed and the picker never populated.)

The JavaScript engine (real code, abridged)

javascript
/*
 * local-datetime-picker.js
 * ------------------------
 * A small, dependency-free datetime picker + timezone-awareness layer for Rails.
 *
 * Datetime picker:
 *   1. SHOWS the date/time in the USER'S LOCAL time zone (native date/time inputs
 *      rendered by the browser in the visitor's local timezone).
 *   2. SUBMITS a UTC timestamp (hidden input) -> stored in the DB as UTC.
 *   3. On EDIT, the widget is populated with the stored value (converted UTC -> local).
 *   4. On CREATE, the widget defaults to the current local date & time.
 *
 * Timezone awareness (for users who VPN / cross time zones):
 *   * The header clock shows the current time in BOTH the user's local zone and
 *     UTC, and labels each with the ACTUAL zone abbreviation (e.g. EDT, CEST)
 *     plus the IANA zone id (e.g. America/New_York) via the Intl API.
 *   * localizeUtcStamps() rewrites index/show timestamps to the viewer's local
 *     zone, labelled with the real abbreviation + IANA id.
 *   * detectZone() compares the browser's OS timezone (Intl
 *     resolvedOptions().timeZone) against the timezone implied by the device's
 *     CURRENT local wall-clock time. A mismatch is the classic "I'm on a VPN /
 *     physically in another time zone but my OS clock still says home" case, and
 *     we surface a clear warning banner so the user is never confused.
 *
 * NOTE: the browser can only ever know the device's OS timezone. A VPN changes
 * the IP (and server-side geolocation) but NOT the browser timezone, so the OS
 * zone reported by Intl is the device's, not the VPN's. detectZone() catches the
 * resulting confusion (OS zone vs. actual wall-clock zone) and flags it.
 */
(function () {
  "use strict";

  // ---------- small helpers ----------
  function p2(n) { return String(n).padStart(2, "0"); }

  function fromLocalFields(y, mo, d, hh, mm) {
    return new Date(y, mo - 1, d, hh, mm, 0, 0);
  }

  function toUtcString(date) {
    return date.toISOString().slice(0, 19) + "Z";
  }

  function localFields(date) {
    return {
      y: date.getFullYear(), m: date.getMonth() + 1, d: date.getDate(),
      h: date.getHours(), min: date.getMinutes(),
    };
  }

  /**
   * Parse a datetime string that may carry a timezone designator
   * (Rails/MariaDB "2026-08-18 16:30:00 UTC" or an ISO string). Returns Date|null.
   */
  function parseServerDateTime(str) {
    if (!str) return null;
    str = String(str).trim();
    if (!str) return null;

    var m = str.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?\s*(UTC|Z|[+-]\d{2}:?\d{2}|[A-Z]{2,4})?$/);
    if (m) {
      var y = +m[1], mo = +m[2] - 1, d = +m[3], hh = +m[4], mi = +m[5], s = +m[6];
      var frac = m[7] ? Number(("0." + m[7])) : 0;
      var tz = (m[8] || "UTC");
      if (tz === "UTC" || tz === "Z") {
        return new Date(Date.UTC(y, mo, d, hh, mi, s, Math.round(frac * 1000)));
      }
      var off = tz.match(/^([+-])(\d{2}):?(\d{2})$/);
      if (off) {
        var sign = off[1] === "-" ? -1 : 1;
        var offMin = sign * (+off[2] * 60 + +off[3]);
        return new Date(Date.UTC(y, mo, d, hh, mi, s, Math.round(frac * 1000)) - offMin * 60000);
      }
      return new Date(Date.UTC(y, mo, d, hh, mi, s, Math.round(frac * 1000)));
    }

    var native = new Date(str);
    return isNaN(native.getTime()) ? null : native;
  }

  // ---------- timezone introspection (Intl API) ----------

  /** IANA zone id the browser is running in (e.g. "America/New_York"). */
  function osZoneId() {
    try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "unknown"; }
    catch (e) { return "unknown"; }
  }

  /** Short zone abbreviation at a given instant (e.g. "EDT", "CEST", "UTC"). */
  function zoneAbbr(tz, date) {
    try {
      var parts = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "short" }).formatToParts(date || new Date());
      for (var i = 0; i < parts.length; i++) if (parts[i].type === "timeZoneName") return parts[i].value;
    } catch (e) {}
    return "";
  }

  /**
   * What timezone does the device's CURRENT wall-clock time actually live in?
   * We take the device's local calendar fields, convert to an absolute instant,
   * then ask Intl which IANA zone renders that instant as the same wall time.
   * If the user has physically moved time zones but the OS clock still reports
   * the old one, this returns the zone of the CURRENT wall time (the new zone).
   */
  function wallClockZone(now) {
    var f = localFields(now);
    var instant = Date.UTC(f.y, f.m - 1, f.d, f.h, f.min, 0, 0); // "treat local fields as UTC"
    var candidates = [
      "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
      "America/Sao_Paulo", "America/Mexico_City", "America/Bogota", "America/Lima",
      "Europe/London", "Europe/Dublin", "Europe/Paris", "Europe/Berlin", "Europe/Madrid",
      "Europe/Rome", "Europe/Stockholm", "Europe/Warsaw", "Europe/Athens", "Europe/Istanbul",
      "Africa/Cairo", "Africa/Johannesburg", "Africa/Lagos", "Africa/Nairobi",
      "Asia/Dubai", "Asia/Kolkata", "Asia/Singapore", "Asia/Bangkok", "Asia/Hong_Kong",
      "Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul", "Australia/Sydney", "Australia/Melbourne",
      "Pacific/Auckland", "Pacific/Honolulu", "America/Anchorage", "America/Denver",
      "UTC", "Etc/UTC"
    ];
    for (var i = 0; i < candidates.length; i++) {
      var c = candidates[i];
      try {
        var parts = new Intl.DateTimeFormat("en-GB", {
          timeZone: c, year: "numeric", month: "2-digit", day: "2-digit",
          hour: "2-digit", minute: "2-digit", hour12: false
        }).formatToParts(new Date(instant));
        var vals = {};
        parts.forEach(function (pp) { vals[pp.type] = pp.value; });
        if (+vals.year === f.y && +vals.month === f.m && +vals.day === f.d &&
            (+vals.hour % 24) === f.h && +vals.minute === f.min) {
          return c;
        }
      } catch (e) {}
    }
    return null;
  }

  /**
   * Detect a timezone discrepancy (the VPN / "physically elsewhere" case).
   * Returns { osZone, wallZone, mismatch: bool, abbr }.
   */
  function detectZone() {
    var os = osZoneId();
    var now = new Date();
    var wall = wallClockZone(now);
    var mismatch = !!(wall && os && wall !== os);
    return { osZone: os, wallZone: wall || os, mismatch: mismatch, abbr: zoneAbbr(os, now) || zoneAbbr(wall, now) };
  }

  // ---------- datetime picker ----------
  function attach(root) {
    var hidden = root.querySelector('[data-dt-hidden]');
    var dateInput = root.querySelector('[data-dt-date]');
    var timeInput = root.querySelector('[data-dt-time]');
    if (!hidden || !dateInput || !timeInput) return;

    function pushToControls(date) {
      var f = localFields(date);
      dateInput.value = f.y + "-" + p2(f.m) + "-" + p2(f.d);
      timeInput.value = p2(f.h) + ":" + p2(f.min);
    }

    function syncHidden() {
      if (!dateInput.value || !timeInput.value) return;
      var dm = dateInput.value.split("-");
      var tm = timeInput.value.split(":");
      var date = fromLocalFields(+dm[0], +dm[1], +dm[2], +tm[0], +tm[1]);
      hidden.value = toUtcString(date);
    }

    var rawInitial = hidden.getAttribute("data-dt-initial") || hidden.value || "";
    var initial = parseServerDateTime(rawInitial);
    if (initial) { pushToControls(initial); } else { pushToControls(new Date()); }
    syncHidden();

    dateInput.addEventListener("input", syncHidden);
    timeInput.addEventListener("input", syncHidden);
    dateInput.addEventListener("change", syncHidden);
    timeInput.addEventListener("change", syncHidden);

    var preview = root.querySelector('[data-dt-preview]');
    function refreshPreview() {
      if (!preview) return;
      var dm = dateInput.value.split("-");
      var tm = timeInput.value.split(":");
      if (!dm.length || !tm.length) { preview.textContent = ""; return; }
      var date = fromLocalFields(+dm[0], +dm[1], +dm[2], +tm[0], +tm[1]);
      var abbr = zoneAbbr(osZoneId(), date);
      preview.textContent = "Local " + (abbr ? "(" + abbr + ") " : "") + date.toLocaleString() +
        "  ·  stored as " + toUtcString(date) + " UTC";
    }
    dateInput.addEventListener("input", refreshPreview);
    timeInput.addEventListener("input", refreshPreview);
    refreshPreview();
  }

  // ---------- localize stored UTC stamps to the viewer's zone ----------
  function localizeUtcStamps() {
    var info = detectZone();
    document.querySelectorAll('.dt-local[data-utc]').forEach(function (el) {
      var d = parseServerDateTime(el.getAttribute("data-utc"));
      if (!d) return;
      var abbr = info.abbr || zoneAbbr(info.osZone, d);
      el.textContent = d.toLocaleString() + (abbr ? " " + abbr : "") + " (local)";
      el.title = "Stored in DB as UTC: " + el.getAttribute("data-utc") +
        " · Your zone: " + info.osZone;
    });
  }

  // ---------- header dual clock (local + UTC) with real zone labels ----------
  function tickClock() {
    var el = document.getElementById("dual-clock");
    if (!el) return;
    var now = new Date();
    var info = detectZone();
    var localVal = el.querySelector('[data-clock="local"] .clock-val');
    var utcVal = el.querySelector('[data-clock="utc"] .clock-val');
    var localAbbr = el.querySelector('[data-clock="local"] .clock-abbr');
    var utcAbbr = el.querySelector('[data-clock="utc"] .clock-abbr');
    if (localVal) localVal.textContent = now.toLocaleString();
    if (localAbbr) localAbbr.textContent = info.abbr || info.osZone;
    if (utcVal) utcVal.textContent = now.toLocaleString("en-GB", { timeZone: "UTC" });
    if (utcAbbr) utcAbbr.textContent = "UTC";
    el.setAttribute("data-now", toUtcString(now));
    el.setAttribute("data-os-zone", info.osZone);
    el.setAttribute("data-wall-zone", info.wallZone);
  }

  // ---------- timezone-mismatch warning banner (VPN / cross-zone) ----------
  function renderZoneWarning() {
    var info = detectZone();
    var bar = document.getElementById("tz-warning");
    if (bar) bar.remove();
    if (!info.mismatch) return;

    var el = document.createElement("div");
    el.id = "tz-warning";
    el.className = "tz-warning";
    el.setAttribute("role", "alert");
    var msg = document.createElement("div");
    msg.className = "tz-warning-msg";
    msg.innerHTML =
      "&#9888; <strong>Timezone mismatch.</strong> Your browser's OS timezone is " +
      "<code>" + info.osZone + "</code> but your current local clock is set to a time in " +
      "<code>" + info.wallZone + "</code>. If you are on a VPN or physically in a different " +
      "time zone, the times shown use your device's OS zone. Double-check before saving.";
    el.appendChild(msg);
    var main = document.querySelector("main") || document.body;
    main.insertBefore(el, main.firstChild);
  }

  // ---------- boot ----------
  document.addEventListener("DOMContentLoaded", function () {
    document.querySelectorAll('[data-datetime-widget]').forEach(attach);
    localizeUtcStamps();
    renderZoneWarning();
    tickClock();
    setInterval(function () { tickClock(); }, 1000);
  });
})();
work/app/assets/javascripts/local-datetime-picker.js — the picker + clock + zone detection

The lifecycle:

  1. On load, read the hidden input’s value (the stored UTC on edit, or the server-seeded “now” on create), parse it, and push it into the two visible boxes using the local calendar fields (getFullYear/getMonth/getDate/getHours).
  2. On input, read the two local boxes, build a local Date, and write its toISOString() (UTC) into the hidden field.
  3. Live preview under the boxes shows both the local reading and the UTC value that will be stored — transparency kills the “what time did it save as?” class of tickets.
Round-trip guarantee. Because the same Date object is the source for both the local display and the UTC submit, what the user sees and what the DB stores are two views of one instant — they can never drift apart. Pick 14:30 in EDT, and the DB gets 18:30:00Z, every time.
Chapter 05

Luxon, and why we moved off moment.js

moment.js ruled front-end time for a decade. It was the first library that made parsing, formatting, and arithmetic pleasant. But it carried two structural debts that became impossible to ignore:

Debt 1 — it mutated by default

moment() objects are mutable: m.add(1,"day") changes m. In a framework where you pass a value around, a surprise mutation in one place corrupts it everywhere. Luxon’s DateTime is immutable — every operation returns a new object. No shared-state bugs, and it composes cleanly with Redux-style state.

Debt 2 — it carried a copy of the world

moment bundled its own locale and zone data, so a “moment in Paris” could disagree with the platform’s idea of Paris. Luxon is built directly on the platform’s Intl API — the same engine the browser and Node use — so zone rules, DST transitions, and locale formatting are always current and consistent with the rest of the runtime. No stale data, no “which Paris is this?”

And then moment stopped

The clincher was the 2018 “moment.js is no longer being maintained” announcement. A library that has date parsing as its core job needs continuous, careful maintenance (new IANA releases, security fixes, ICU drift). Its quiet retirement in favor of Luxon (by the same team) is the story in one line: immutable + built on Intl + actively maintained.

Luxon in practice

javascript
// The three operations this article is about, in Luxon
import { DateTime } from "luxon";

// 1) Create / parse — always pin the zone explicitly
const nowLocal = DateTime.now();                         // visitor's OS zone
const utc      = DateTime.fromISO("2026-08-19T18:30:00Z");

// 2) Convert: store UTC, display local
utc.toUTC().toISO();                       // "2026-08-19T18:30:00.000Z"  (what the DB stores)
utc.setZone("America/New_York").toLocaleString();  // "8/19/2026, 2:30 PM"  (what a NY user sees)
utc.setZone("Asia/Tokyo").toLocaleString();        // "8/20/2026, 3:30 AM"  (same instant, Tokyo)

// 3) The real zone label, for display
utc.setZone("America/New_York").zoneName;  // "America/New_York"
DateTime.now().setZone("America/New_York").toFormat("EEE LLLL d, h:mm a ZZZZ");
// "Tue August 19, 2:30 PM EDT"
The same three operations — parse, convert, label — in Luxon
Native Intl vs Luxon. Our demo uses plain Intl + Date — zero dependencies — because for “store UTC, show local, label the zone” the platform API is enough. Reach for Luxon when you need richer recurring rules, timezone math, or a consistent API across an app. They’re not competitors for this job; Luxon is the same philosophy with a friendlier surface.
Chapter 06

World time formats

The value of a time is universal; the spelling of it is local. If you render 8/19/2026 to a user who reads dates as day-first, you’ve shown them a date in August or the 19th — and if it were 3/4, that’s March 4th or April 3rd. Format for the audience, and prefer ISO 8601 for anything machine-readable.

RegionTypical date formatTypical clockNotes
United StatesMM/DD/YYYY (e.g. 08/19/2026)12-hour, AM/PM (e.g. 2:30 PM)Day-first is rare; 12/31 is unambiguous, 3/4 is not.
Most of EuropeDD/MM/YYYY (e.g. 19/08/2026)Often 24-hour (e.g. 14:30)The ISO-adjacent default; unambiguous day-first.
China / much of AsiaYYYY-MM-DD (e.g. 2026-08-19)24-hour “military” (e.g. 14:30)Year-first, ISO-style; the 24-hour clock is the norm.
Machines / APIsYYYY-MM-DDHH:mm:ss + zoneISO 8601 (2026-08-19T18:30:00Z) — sort-safe, unambiguous.

Practical rules

Chapter 07

The daylight-saving landmines

Daylight saving time impact on a timeline
Twice a year the local timeline bends by an hour. Software that assumes 24-hour days breaks here.

Daylight saving is where “store UTC, show local” stops being optional and starts being load-bearing. A zone with DST means the offset to UTC is not constant — it changes twice a year. If your code treats the offset as a fixed -05:00, you are 60 minutes wrong for half the year.

Landmine 1 — the “spring-forward hole”

When clocks spring forward, some local times do not exist. In the US on the second Sunday of March, the clock jumps from 01:59 to 03:00. There is no 2:30 AM that day. If a user (or a bad server default) hands you “March 8, 2:30,” there is no UTC instant it maps to — you must have a policy (clamp to 3:00, or reject).

Spring forward (US, 2026) — March 8, 02:00 → 03:00 local 00:00 02:00 04:00 06:00 08:00 10:00 12:00 14:00 16:00 18:00 20:00 22:00 24:00 no such time clocks jump +1h If a user (or a bad default) stores "March 8, 2:30 AM", there is no UTC instant it maps to.
The gap. The red dashed segment is a range of local times with no corresponding instant.

Landmine 2 — the “fall-back fold” (ambiguous times)

When clocks fall back, one hour happens twice. “November 1, 1:30” occurs at two different UTC instants (1:30 EDT and 1:30 EST). A local time alone is ambiguous; only the zone + a fold indicator disambiguates. The same failure, mirror image: the 23-hour day and the 25-hour day.

Length of a local "day" in UTC hours, around each DST transition 22h 23h 24h 25h 24h Normal 25h Fall back (Nov) 24h Normal 23h Spring forward (Mar) A "day" is 25 UTC-hours when clocks fall back, 23 when they spring forward.
A “day” is 25 UTC-hours after the fall-back and 23 after the spring-forward. Any code that assumes 86,400 seconds per day is wrong twice a year.

Landmine 3 — charts that span a DST boundary

This is the one that silently corrupts analytics. Imagine an hourly throughput line chart for a day. If you bucket by local hour, the spring-forward day only has 23 buckets and the fall-back day has 25 — your axis is a different width than every other day, and a naïve “hours since midnight” axis will stretch or compress the data at the transition. Worse, if you ever compute deltas with a fixed offset, a gap appears exactly where the DST jump happened and the line looks like a real outage.

The rule for time series: store and bucket by UTC (uniform 24×3,600-second slots), and only convert to the viewer’s zone for the axis labels. That way every day is exactly 24 uniform slots, the transition is invisible in the data, and the labels still read “2:00 PM EDT” / “1:00 PM EST” correctly. Never let the DST shift change your bin width.

Both Luxon and Intl handle the transitions correctly because they read the IANA rules; the bugs come from home-grown offset math. If you see a hand-rolled utc + 4h anywhere, that’s where the next incident is hiding.

Chapter 08

See it running

The live demo

Everything in this article is a running Rails 7.2 app on MariaDB — a small CRUD with the UTC-storing, local-displaying datetime widget, the dual local/UTC clock in the header, and the timezone-mismatch warning. Create a record, pick a time, reload the edit page, and watch the value survive the round trip in your own zone.

Coming live — https://demo01.grokonet.com

When it goes live, demo01.grokonet.com reverse-proxies (nginx) to the app on port 8093. Until then, on the demo host it is served host-only at:

http://127.0.0.1:8093

Things to try

  • Open the header — note the Local clock with your zone abbreviation next to the UTC clock.
  • Create a record; read the preview line showing the local reading and the UTC value that gets stored.
  • Edit it — the picker prefills in your zone; the DB still holds UTC.
  • Change your OS time zone (or run it through a VPN) and come back — the amber timezone-mismatch banner should fire when the two disagree.

Related

This article was written for grokonet.com. A companion interactive index lives at cellist40 · http://127.0.0.1:8090.