module TimeAgoHelper
def time_ago_tag(time, locale: I18n.locale, **options)
return if time.blank?
time = time.to_time
fallback = l(time, format: :short)
content_tag(
:time,
fallback,
datetime: time.iso8601,
title: l(time, format: :long),
data: {
controller: "time-ago",
time_ago_datetime_value: time.iso8601,
time_ago_locale_value: locale.to_s
},
**options
)
end
end
<article class="comment" id="<%= dom_id(comment) %>">
<header class="comment__meta">
<%= image_tag comment.author.avatar_url, class: "comment__avatar", alt: "" %>
<span class="comment__author"><%= comment.author.name %></span>
<%# The <time> renders a static date, then Stimulus makes it live. %>
<%= time_ago_tag comment.created_at, class: "comment__timestamp" %>
</header>
<div class="comment__body">
<%= sanitize comment.body_html %>
</div>
<% if policy(comment).edit? %>
<%= link_to "Edit", edit_comment_path(comment), class: "comment__edit" %>
<% end %>
</article>
import { Controller } from "@hotwired/stimulus"
const DIVISIONS = [
{ amount: 60, unit: "second" },
{ amount: 60, unit: "minute" },
{ amount: 24, unit: "hour" },
{ amount: 7, unit: "day" },
{ amount: 4.34524, unit: "week" },
{ amount: 12, unit: "month" },
{ amount: Number.POSITIVE_INFINITY, unit: "year" }
]
export default class extends Controller {
static values = { datetime: String, locale: { type: String, default: "en" } }
connect() {
this.formatter = new Intl.RelativeTimeFormat(this.localeValue, { numeric: "auto" })
this.date = new Date(this.datetimeValue)
this.refresh()
}
disconnect() {
if (this.timer) clearTimeout(this.timer)
}
refresh() {
this.element.textContent = this.format()
this.scheduleNext()
}
format() {
let delta = Math.min((this.date.getTime() - Date.now()) / 1000, 0)
for (const division of DIVISIONS) {
if (Math.abs(delta) < division.amount) {
return this.formatter.format(Math.round(delta), division.unit)
}
delta /= division.amount
}
}
scheduleNext() {
const ageSeconds = (Date.now() - this.date.getTime()) / 1000
if (ageSeconds > 86400) return // older than a day: stop ticking
const cadence = ageSeconds < 60 ? 5000 : 60000
this.timer = setTimeout(() => this.refresh(), cadence)
}
}
This snippet builds relative timestamps ("3 minutes ago", "2 days ago") that update themselves in the browser without pulling in Moment.js or date-fns. The whole thing leans on Intl.RelativeTimeFormat, a native browser API that handles locale-aware pluralization and phrasing for free, so the client-side footprint stays near zero.
The time_ago_tag helper renders a semantic <time> element with a machine-readable datetime attribute in ISO 8601. That attribute is the single source of truth: the server never guesses what "ago" means at render time, it just emits the absolute instant. The element carries data-controller="time-ago" and a data-time-ago-datetime-value so the Stimulus controller can hydrate it. Crucially, the tag also renders a human-readable fallback inside the element, so if JavaScript is disabled or slow the reader still sees a real date — this is progressive enhancement rather than a blank node.
In time_ago_controller.js, the datetimeValue typed value auto-parses the ISO string into a Date. On connect() the controller renders once and then starts an interval. The refresh() method computes the delta and calls format(), which walks a small table of thresholds (minute, hour, day, week, month, year) and picks the largest unit that fits, passing a negative value to RelativeTimeFormat so the API produces past-tense phrasing.
Two details make it production-safe. The interval cadence adapts in scheduleNext(): very recent times refresh every few seconds, older ones every minute, and anything past a day stops ticking entirely since "5 days ago" does not need per-second churn. And disconnect() clears the timer, which matters under Turbo where controllers connect and disconnect as frames swap — without cleanup, orphaned intervals would leak and multiply.
The controller also sets the element's title to the full localized timestamp so hovering reveals the exact time, keeping both quick-scan and precise readings available. The trade-off is that clock skew between server and client can make brand-new items read as "in a few seconds"; the Math.min(delta, 0) guard clamps that so future-ish drift still shows "just now" rather than a confusing future phrase.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Semantic HTML Example</title>
Semantic HTML5 elements and accessibility best practices
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.