Module: Studio::AtTimeHelper

Defined in:
app/helpers/studio/at_time_helper.rb

Overview

The "at" format — the shared primitive for stamping WHEN something happened.

A relative stamp ("Shipped less than a minute ago") answers how long ago and then stops. It never tells the reader what the clock said, so it cannot be placed against the rest of a day. The "at" format answers the other question, and it answers it in the reader's own time:

· The clock is 12-hour with a single-letter meridiem — "3:53p".
· The date appears ONLY when the stamp is not today. Something that happened
minutes ago needs no date; last week's is ambiguous without it. The year
joins only when it differs, so "Aug 10, 3:53p" stays short all year.
· A country flag TRAILS the clock when the reader's timezone sits outside the
US. Inside the US there is no flag at all — the flag carries signal only
because it is unusual, and one that fired on every stamp would carry none.
· The relative phrase is not thrown away; it moves to the hover title, beside
the full local stamp and its zone name.

WHO OWNS WHAT. The server renders the app-timezone form as the pre-hydration and no-JS fallback, and never renders a flag — it cannot know where the reader is sitting. studio/_at_time_script re-stamps the clock to the VIEWER's local time and adds the flag from the browser's IANA zone. That split is the whole design: the flag is a fact about the reader, so only the reader's machine may assert it.

HOSTS: render studio/at_time_script ONCE per page, near the END of the layout body (its first pass runs synchronously, so rendering it in head finds zero stamps), then use at_time_tag anywhere. Recipe in the README's UI Primitives section. Without the script the stamps still render — in the app's timezone, with no flag — so a host that forgets it degrades to the old behavior rather than breaking. Specimen: /admin/style → Tricks → Time stamps.

Constant Summary collapse

MONTH_DAY =
"%b %-d"

Instance Method Summary collapse

Instance Method Details

#at_clock(time) ⇒ Object

"3:53p" / "11:07a" — 12-hour clock, no leading zero, single-letter meridiem, no space. Zones the time into the app zone first, so a bare UTC timestamp and an already-zoned one render the same. nil-safe.



40
41
42
43
44
45
# File 'app/helpers/studio/at_time_helper.rb', line 40

def at_clock(time)
  return nil if time.blank?

  local = time.in_time_zone
  "#{local.strftime('%-l:%M')}#{local.hour < 12 ? 'a' : 'p'}"
end

#at_date(time, now: Time.current) ⇒ Object

The date half of an "at" stamp, or nil when the stamp falls on now's date — today's stamps carry no date. "Aug 10" within the current year, "Aug 10 2025" outside it. now is injectable so the boundary is testable.



50
51
52
53
54
55
56
57
58
59
60
# File 'app/helpers/studio/at_time_helper.rb', line 50

def at_date(time, now: Time.current)
  return nil if time.blank?

  local = time.in_time_zone
  today = now.in_time_zone
  return nil if local.to_date == today.to_date

  return local.strftime(MONTH_DAY) if local.year == today.year

  "#{local.strftime(MONTH_DAY)} #{local.year}"
end

#at_stamp_text(time, now: Time.current) ⇒ Object

The visible text of an "at" stamp: "3:53p" today, "Aug 10, 3:53p" otherwise. The JS half builds the identical string from the viewer's clock — change one and change the other, or the value flickers on hydration.



65
66
67
68
69
70
# File 'app/helpers/studio/at_time_helper.rb', line 65

def at_stamp_text(time, now: Time.current)
  return nil if time.blank?

  date = at_date(time, now: now)
  date ? "#{date}, #{at_clock(time)}" : at_clock(time)
end

#at_stamp_title(time, now: Time.current) ⇒ Object

The hover title: the relative phrase this format replaced, then the full local stamp with its zone. Server-side that zone is the app's; the script rewrites the whole title in the viewer's.

now is measured against, not decoration: time_ago_in_words reads the real clock, so a caller that injected now used to get a title from a DIFFERENT moment than the label right beside it.



79
80
81
82
83
84
# File 'app/helpers/studio/at_time_helper.rb', line 79

def at_stamp_title(time, now: Time.current)
  return nil if time.blank?

  local = time.in_time_zone
  "#{distance_of_time_in_words(local, now)} ago · #{local.strftime('%a, %b %-d, %Y, %-l:%M %p %Z')}"
end

#at_time_tag(time, prefix: "at", now: Time.current, css_class: nil) ⇒ Object

The primitive itself. Renders a

at_time_tag(release.shipped_at)              => "at 3:53p"
at_time_tag(task.created_at, prefix: nil)    => "3:53p"

nil for a blank time, so a caller can <%= at_time_tag(t) %> unguarded.



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'app/helpers/studio/at_time_helper.rb', line 93

def at_time_tag(time, prefix: "at", now: Time.current, css_class: nil)
  return nil if time.blank?

  # One reading of "no prefix" for BOTH halves. `.presence` folds nil, "" and
  # false to nil, and the data attribute is then omitted rather than carrying a
  # stringified value — `prefix: false` used to render bare server-side and
  # hydrate to "false 3:53p", because the client trusts the attribute it is given.
  prefix = prefix.presence
  local = time.in_time_zone
  text = at_stamp_text(local, now: now)
  text = "#{prefix} #{text}" if prefix

  tag.time(datetime: local.iso8601,
           title: at_stamp_title(local, now: now),
           class: ["whitespace-nowrap", css_class].compact.join(" "),
           data: { at_stamp: "", at_epoch: local.to_i, at_prefix: prefix }) do
    # No whitespace between the two slots: the gap is `ml-2` on the flag, which
    # collapses with the flag itself when the reader is inside the US.
    safe_join([
      tag.span(text, data: { at_text: "" }),
      tag.span("", class: "ml-2", hidden: true, data: { at_flag: "" })
    ])
  end
end