Open-source project
basecamp/local_time avatar
basecamp/local_time

Local Time: cache-safe timezone display in Rails via UTC time attributes

Rails engine for cache-friendly, client-side local time

2,002 stars124 forksJavaScriptMIT

At a glance

What is it?
Local Time is a Rails engine and npm package that renders UTC timestamps in time elements for client-side timezone conversion, keeping pages cache-safe. Version 3.0.3 ships under the MIT license, and the built-in JavaScript strftime layer covers a partial subset of the standard directives.
Who is it for?
Local Time is worth adding to any Rails application that caches HTML at the CDN or HTTP layer and serves users in multiple timezones. The UTC-in-attribute design removes timezone-aware rendering entirely, and the five relative types cover most display needs.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 91 days ago.
What is it written in?
Mainly JavaScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 11, 2026, and from our analysis. They are not legal advice.

Editorial analysis

UTC in the datetime attribute is what makes the HTML safe to cache

The hard problem with serving timestamps in Rails is not converting them, it is doing so without breaking cache reuse. If the server renders a time string in the viewer's local timezone, the HTML differs for every viewer and nothing at the CDN layer can be shared. Local Time sidesteps this by keeping server output in UTC and handing the conversion to the browser.

Calling local_time(comment.created_at) in an ERB template writes a time element whose datetime attribute holds a UTC datetime string. The element content at render time is the server's own formatted output, which acts as a no-JavaScript fallback. The JavaScript component then reads the datetime attribute, converts the UTC value using the browser's timezone data, and replaces the element text with the local equivalent.

Because every viewer receives the same UTC string in the datetime attribute, the HTML is identical for all of them. A CDN edge can cache that response without knowing any viewer's timezone. No timezone cookie, no per-user cache key variation, and no second request to fetch a localized version are required.

Importmaps setup adds a turbo:morph hook that the Webpacker instructions omit

The library needs two components: the local_time gem for the Ruby side and the local-time npm package for the JavaScript side.

For the Importmaps path, add gem "local_time" to the Gemfile, then run:

bash
bundle install
bin/importmap pin local-time

Then add this to app/javascript/application.js:

js
import LocalTime from "local-time"
LocalTime.start()
document.addEventListener("turbo:morph", () => {
  LocalTime.run()
})

For the Webpacker path, add gem "local_time" to the Gemfile, then run:

bash
bundle install
yarn add local-time

Then add this to app/javascript/packs/application.js:

js
import LocalTime from "local-time"
LocalTime.start()

The Webpacker example omits the turbo:morph listener. When Turbo morphs the DOM rather than replacing it, existing time elements may receive new datetime attributes without triggering the initial LocalTime.start() scan. Without that listener, those elements will not be re-converted. A Webpacker project that relies on Turbo morphing needs to add the listener manually, since the README only shows it in the Importmaps example.

local_time and local_date scan the same four format sources in different orders

Passing a symbol as the format argument to local_time or local_date resolves the name through four sources, but the two helpers search them in different sequences.

For local_time with a symbol format, the lookup order is I18n.t("time.formats.#{format}"), then I18n.t("date.formats.#{format}"), then Time::DATE_FORMATS[format], then Date::DATE_FORMATS[format]. For local_date with the same symbol, the I18n checks are reversed: it starts with I18n.t("date.formats.#{format}"), then I18n.t("time.formats.#{format}"), then Date::DATE_FORMATS[format], then Time::DATE_FORMATS[format].

A format name defined identically in both I18n.t("time.formats") and I18n.t("date.formats") will be found at position one by both helpers. A name that differs between those two locations will produce different output depending on which helper is called. This lookup divergence is worth tracing when a named format symbol renders unexpectedly in one helper but not the other, since the same symbol resolving to different format strings in the two sources is the probable cause. For a literal string argument rather than a symbol, no lookup happens and the string is passed directly.

local_time_ago updates its output every 60 seconds through age-based transitions

Calling local_time_ago(time) renders a time element with data-local="time-ago". The JavaScript component updates these elements every 60 seconds for as long as the page is open.

The display follows a sequence of age-based rules. A recent event shows a count in seconds, then minutes, then hours: "a second ago", "32 seconds ago", "an hour ago", "14 hours ago". After that, the format shifts to a combined date and time for events from yesterday or earlier in the week, such as "yesterday at 5:22pm" or "Tuesday at 12:48am". Older events show a date alone: "on Nov 17" for events in the current year, or "on Jan 31, 2012" for events in a prior year.

The 60-second update interval is fixed. No documented configuration option changes it. The labels in the output, including day names, month names, and separators, come from the I18n configuration, which is editable per locale as described below.

local_relative_time exposes five preset types and accepts no custom format string

local_time_ago is a convenience wrapper around local_relative_time called with the time-ago type. Calling local_relative_time directly lets you select from five named types, each with its own switching rule:

erb
<%= local_relative_time(time, 'weekday') %>
<%= local_relative_time(time, type: 'time-or-date') %>

The five available types are: date, which includes the year only when it differs from the current year; time-ago, identical to local_time_ago; time-or-date, which shows the time for events today and the date for everything else; weekday, which shows "Today", "Yesterday", or the day name for events within a week; and weekday-or-date, which shows the weekday within a week and the date outside it.

None of the five types accept a custom strftime format string. They are preset display modes, not format wrappers. When none of the five fits a given display requirement, local_time or local_date with an explicit format is the appropriate path.

The JavaScript strftime parser covers a documented set of directives and leaves others undocumented

Local Time bundles its own JavaScript strftime parser rather than delegating to Intl.DateTimeFormat. The supported directives are: %a %A %b %B %c %d %e %H %I %l %m %M %p %P %S %w %y %Y %Z. Directives outside that set, such as %j for day of year, %U and %W for week numbers, or %n for a newline character, are not in the documented list.

The README describes the implementation as "not 100% complete". It does not say what happens to unsupported directives: whether they pass through as literals, are silently omitted, or produce a rendering error in a given browser. Before using a format string in production, check that every percent-letter combination it contains appears in the list above.

This gap matters most when format strings come from I18n locale files shared with other libraries. A locale file written for Ruby's built-in strftime or for a separate JavaScript date library may include directives that Local Time's parser does not handle, and the resulting behavior is not documented.

24-hour mode requires paired format names in DATE_FORMATS and a config flag set before rendering

Supporting 24-hour time involves two coordinated steps, one in JavaScript and one in Ruby.

On the JavaScript side, set:

js
LocalTime.config.useFormat24 = true

This tells the library to look for a data-format24 attribute on each time element first, falling back to data-format when data-format24 is not present.

On the Ruby side, define a paired format name in DATE_FORMATS:

ruby
Time::DATE_FORMATS[:simple] = "%-l:%M%P"
Time::DATE_FORMATS[:simple_24h] = "%H:%M"

When useFormat24 is true, the Rails helpers look for a #{name}_24h variant in the same four lookup sources the base name uses. Calling local_time(time, :simple) with the config flag enabled picks up :simple_24h automatically.

For a literal format string rather than a named symbol, the helpers have no _24h variant to resolve, so the 24-hour branch has to be selected in application code before the format reaches local_time. The README notes that the config flag would typically be set based on the current user's preference, before any time elements are rendered.

I18n locale config changes RelativeTime labels but does not select the default format pattern

Adding a locale for the library is two steps: define the locale structure and set it as active:

js
LocalTime.config.i18n["es"] = {
  date: {
    dayNames: [ … ],
    monthNames: [ … ],
    …
  },
  time: {
    …
  },
  datetime: {
    …
  }
}
LocalTime.config.locale = "es"

The default English translations live at lib/assets/javascripts/src/local-time/config/i18n.js in the repository and serve as the template to copy when building a new locale.

There is a documented gotcha: the "default" keys inside the i18n object are used by the RelativeTime module for the labels that appear in time-ago and relative outputs. They do not determine which strftime format renders when no format argument is given to a helper. The README links to GitHub issue 128 on this point.

Setting a locale localizes day names, month names, and relative time labels, but does not change the format pattern applied by default. Locale-specific format patterns have to be chosen in application code rather than through the i18n configuration, since the configuration only covers the label strings.

Editorial conclusion

Local Time is worth adding to any Rails application that caches HTML at the CDN or HTTP layer and serves users in multiple timezones. The UTC-in-attribute design removes timezone-aware rendering entirely, and the five relative types cover most display needs. Skip it when your format strings need directives outside the library's documented subset, or when those strings come from a locale file written for a different library. Before deploying, confirm every directive in your format strings appears in the documented list and test the turbo:morph listener if your app uses Turbo morphing.

Frequently asked questions

How does Local Time keep Rails pages cache-safe when users are in different timezones?

The gem renders UTC timestamps in the datetime attribute of each time element, so the HTML is identical for every viewer. The JavaScript component reads that UTC value after the page loads and converts it to the browser's local timezone.

How do I install Local Time in a Rails app?

Add gem "local_time" to the Gemfile and run bundle install. Then add the local-time npm package with bin/importmap pin local-time for Importmaps or yarn add local-time for Webpacker, and call LocalTime.start() in your application JavaScript.

What is the difference between local_time and local_relative_time in Rails?

local_time renders a static formatted time converted to the viewer's timezone. local_relative_time renders a time that changes its display based on how much time has passed, using one of five preset types: date, time-ago, time-or-date, weekday, and weekday-or-date.

What strftime directives does Local Time support in JavaScript?

The JavaScript strftime implementation supports the following directives: %a %A %b %B %c %d %e %H %I %l %m %M %p %P %S %w %y %Y %Z. The README describes the implementation as not 100% complete.

How do I configure Local Time for 24-hour clock display?

Set LocalTime.config.useFormat24 = true in JavaScript before any time elements are rendered. On the Ruby side, define a paired format key with the _24h suffix in DATE_FORMATS, such as Time::DATE_FORMATS[:simple_24h], and the helpers will pick it up for named format symbols.

Official sources

  1. basecamp/local_time on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/basecamp-local-time.svg)](https://hysenlabs.com/projects/basecamp-local-time)