feat(TimeAgo): add TimeAgoComponent
MODIFY
CHANGELOG.md
CHANGELOG.md
* **Breaking change**: Upgrade `StateComponent` to support functional colors. This change requires using [@primer/css-next](https://www.npmjs.com/package/@primer/css-next). The required changes will be upstreamed to @primer/css at a later date.
+* Add `TimeAgo` component.
+
+ *Keith Cirkel*
+
## 0.0.21
* **Breaking change**: Upgrade `FlashComponent` to use Slots V2.

MODIFY
Rakefile
Rakefile
@@ -147,6 +147,7 @@ namespace :docs do
Primer::SpinnerComponent,
Primer::SubheadComponent,
Primer::TextComponent,
+ Primer::TimeAgoComponent,
Primer::TimelineItemComponent,
Primer::TooltipComponent,
Primer::TruncateComponent,

MODIFY
app/assets/javascripts/primer_view_components.js
app/assets/javascripts/primer_view_components.js
-
+const t=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],e=["January","February","March","April","May","June","July","August","September","October","November","December"];function n(t){return"0".concat(t).slice(-2)}function r(a,o){const i=a.getDay(),c=a.getDate(),s=a.getMonth(),u=a.getFullYear(),m=a.getHours(),d=a.getMinutes(),l=a.getSeconds();return o.replace(/%([%aAbBcdeHIlmMpPSwyYZz])/g,(function(o){let h;switch(o[1]){case"%":return"%";case"a":return t[i].slice(0,3);case"A":return t[i];case"b":return e[s].slice(0,3);case"B":return e[s];case"c":return a.toString();case"d":return n(c);case"e":return String(c);case"H":return n(m);case"I":return n(r(a,"%l"));case"l":return String(0===m||12===m?12:(m+12)%12);case"m":return n(s+1);case"M":return n(d);case"p":return m>11?"PM":"AM";case"P":return m>11?"pm":"am";case"S":return n(l);case"w":return String(i);case"y":return n(u%100);case"Y":return String(u);case"Z":return h=a.toString().match(/\((\w+)\)$/),h?h[1]:"";case"z":return h=a.toString().match(/\w([+-]\d\d\d\d) /),h?h[1]:""}return""}))}function a(t){let e;return function(){if(e)return e;if("Intl"in window)try{return e=new Intl.DateTimeFormat(void 0,t),e}catch(t){if(!(t instanceof RangeError))throw t}}}let o=null;const i=a({day:"numeric",month:"short"});function c(){if(null!==o)return o;const t=i();if(t){const e=t.format(new Date(0));return o=!!e.match(/^\d/),o}return!1}let s=null;const u=a({day:"numeric",month:"short",year:"numeric"});function m(t){const e=t.closest("[lang]");return e instanceof HTMLElement&&e.lang?e.lang:"default"}const d=new WeakMap;class l extends HTMLElement{static get observedAttributes(){return["datetime","day","format","lang","hour","minute","month","second","title","weekday","year"]}connectedCallback(){const t=this.getFormattedTitle();t&&!this.hasAttribute("title")&&this.setAttribute("title",t);const e=this.getFormattedDate();e&&(this.textContent=e)}attributeChangedCallback(t,e,n){const r=this.getFormattedTitle();if("datetime"===t){const t=Date.parse(n);isNaN(t)?d.delete(this):d.set(this,new Date(t))}const a=this.getFormattedTitle(),o=this.getAttribute("title");"title"===t||!a||o&&o!==r||this.setAttribute("title",a);const i=this.getFormattedDate();i&&(this.textContent=i)}get date(){return d.get(this)}getFormattedTitle(){const t=this.date;if(!t)return;const e=h();if(e)return e.format(t);try{return t.toLocaleString()}catch(e){if(e instanceof RangeError)return t.toString();throw e}}getFormattedDate(){}}const h=a({day:"numeric",month:"short",year:"numeric",hour:"numeric",minute:"2-digit",timeZoneName:"short"}),g=new WeakMap;class w extends l{attributeChangedCallback(t,e,n){"hour"!==t&&"minute"!==t&&"second"!==t&&"time-zone-name"!==t||g.delete(this),super.attributeChangedCallback(t,e,n)}getFormattedDate(){const t=this.date;if(!t)return;const e=function(t,e){const n={weekday:{short:"%a",long:"%A"},day:{numeric:"%e","2-digit":"%d"},month:{short:"%b",long:"%B"},year:{numeric:"%Y","2-digit":"%y"}};let a=c()?"weekday day month year":"weekday month day, year";for(const e in n){const r=n[e][t.getAttribute(e)];a=a.replace(e,r||"")}return a=a.replace(/(\s,)|(,\s$)/,""),r(e,a).replace(/\s+/," ").trim()}(this,t)||"",n=function(t,e){const n={},o=t.getAttribute("hour");"numeric"!==o&&"2-digit"!==o||(n.hour=o);const i=t.getAttribute("minute");"numeric"!==i&&"2-digit"!==i||(n.minute=i);const c=t.getAttribute("second");"numeric"!==c&&"2-digit"!==c||(n.second=c);const s=t.getAttribute("time-zone-name");"short"!==s&&"long"!==s||(n.timeZoneName=s);if(0===Object.keys(n).length)return;let u=g.get(t);u||(u=a(n),g.set(t,u));const m=u();if(m)return m.format(e);return r(e,n.second?"%H:%M:%S":"%H:%M")}(this,t)||"";return"".concat(e," ").concat(n).trim()}}window.customElements.get("local-time")||(window.LocalTimeElement=w,window.customElements.define("local-time",w));class f{constructor(t,e){this.date=t,this.locale=e}toString(){const t=this.timeElapsed();if(t)return t;{const t=this.timeAhead();return t||"on ".concat(this.formatDate())}}timeElapsed(){const t=(new Date).getTime()-this.date.getTime(),e=Math.round(t/1e3),n=Math.round(e/60),r=Math.round(n/60),a=Math.round(r/24);return t>=0&&a<30?this.timeAgoFromMs(t):null}timeAhead(){const t=this.date.getTime()-(new Date).getTime(),e=Math.round(t/1e3),n=Math.round(e/60),r=Math.round(n/60),a=Math.round(r/24);return t>=0&&a<30?this.timeUntil():null}timeAgo(){const t=(new Date).getTime()-this.date.getTime();return this.timeAgoFromMs(t)}timeAgoFromMs(t){const e=Math.round(t/1e3),n=Math.round(e/60),r=Math.round(n/60),a=Math.round(r/24),o=Math.round(a/30),i=Math.round(o/12);return t<0||e<10?y(this.locale,0,"second"):e<45?y(this.locale,-e,"second"):e<90||n<45?y(this.locale,-n,"minute"):n<90||r<24?y(this.locale,-r,"hour"):r<36||a<30?y(this.locale,-a,"day"):o<18?y(this.locale,-o,"month"):y(this.locale,-i,"year")}microTimeAgo(){const t=(new Date).getTime()-this.date.getTime(),e=Math.round(t/1e3),n=Math.round(e/60),r=Math.round(n/60),a=Math.round(r/24),o=Math.round(a/30),i=Math.round(o/12);return n<1?"1m":n<60?"".concat(n,"m"):r<24?"".concat(r,"h"):a<365?"".concat(a,"d"):"".concat(i,"y")}timeUntil(){const t=this.date.getTime()-(new Date).getTime();return this.timeUntilFromMs(t)}timeUntilFromMs(t){const e=Math.round(t/1e3),n=Math.round(e/60),r=Math.round(n/60),a=Math.round(r/24),o=Math.round(a/30),i=Math.round(o/12);return o>=18||o>=12?y(this.locale,i,"year"):a>=45||a>=30?y(this.locale,o,"month"):r>=36||r>=24?y(this.locale,a,"day"):n>=90||n>=45?y(this.locale,r,"hour"):e>=90||e>=45?y(this.locale,n,"minute"):y(this.locale,e>=10?e:0,"second")}microTimeUntil(){const t=this.date.getTime()-(new Date).getTime(),e=Math.round(t/1e3),n=Math.round(e/60),r=Math.round(n/60),a=Math.round(r/24),o=Math.round(a/30),i=Math.round(o/12);return a>=365?"".concat(i,"y"):r>=24?"".concat(a,"d"):n>=60?"".concat(r,"h"):n>1?"".concat(n,"m"):"1m"}formatDate(){let t=c()?"%e %b":"%b %e";var e;return e=this.date,(new Date).getUTCFullYear()!==e.getUTCFullYear()&&(t+=function(){if(null!==s)return s;const t=u();if(t){const e=t.format(new Date(0));return s=!!e.match(/\d,/),s}return!0}()?", %Y":" %Y"),r(this.date,t)}formatTime(){const t=M();return t?t.format(this.date):r(this.date,"%l:%M%P")}}function y(t,e,n){const r=function(t,e){if("Intl"in window&&"RelativeTimeFormat"in window.Intl)try{return new Intl.RelativeTimeFormat(t,e)}catch(t){if(!(t instanceof RangeError))throw t}}(t,{numeric:"auto"});return r?r.format(e,n):function(t,e){if(0===t)switch(e){case"year":case"quarter":case"month":case"week":return"this ".concat(e);case"day":return"today";case"hour":case"minute":return"in 0 ".concat(e,"s");case"second":return"now"}else if(1===t)switch(e){case"year":case"quarter":case"month":case"week":return"next ".concat(e);case"day":return"tomorrow";case"hour":case"minute":case"second":return"in 1 ".concat(e)}else if(-1===t)switch(e){case"year":case"quarter":case"month":case"week":return"last ".concat(e);case"day":return"yesterday";case"hour":case"minute":case"second":return"1 ".concat(e," ago")}else if(t>1)switch(e){case"year":case"quarter":case"month":case"week":case"day":case"hour":case"minute":case"second":return"in ".concat(t," ").concat(e,"s")}else if(t<-1)switch(e){case"year":case"quarter":case"month":case"week":case"day":case"hour":case"minute":case"second":return"".concat(-t," ").concat(e,"s ago")}throw new RangeError("Invalid unit argument for format() '".concat(e,"'"))}(e,n)}const M=a({hour:"numeric",minute:"2-digit"});class b extends l{getFormattedDate(){const t=this.date;if(t)return new f(t,m(this)).toString()}connectedCallback(){T.push(this),A||(D(),A=setInterval(D,6e4)),super.connectedCallback()}disconnectedCallback(){const t=T.indexOf(this);-1!==t&&T.splice(t,1),T.length||A&&(clearInterval(A),A=null)}}const T=[];let A;function D(){let t,e,n;for(e=0,n=T.length;e<n;e++)t=T[e],t.textContent=t.getFormattedDate()||""}window.customElements.get("relative-time")||(window.RelativeTimeElement=b,window.customElements.define("relative-time",b));class F extends b{getFormattedDate(){const t=this.getAttribute("format"),e=this.date;if(e)return"micro"===t?new f(e,m(this)).microTimeAgo():new f(e,m(this)).timeAgo()}}window.customElements.get("time-ago")||(window.TimeAgoElement=F,window.customElements.define("time-ago",F));class E extends b{getFormattedDate(){const t=this.getAttribute("format"),e=this.date;if(e)return"micro"===t?new f(e,m(this)).microTimeUntil():new f(e,m(this)).timeUntil()}}window.customElements.get("time-until")||(window.TimeUntilElement=E,window.customElements.define("time-until",E));

MODIFY
app/components/primer/primer.ts
app/components/primer/primer.ts
+import "./time_ago_component"

ADD
app/components/time_ago_component.rb
+# frozen_string_literal: true
+
+module Primer
+ # A basic wrapper for the "<time-ago>" element.
+ class TimeAgoComponent < Primer::Component
+ def initialize(time:, micro: false, **system_arguments)
+ @system_arguments = system_arguments
+ @system_arguments[:datetime] = time.utc.iso8601
+ @system_arguments[:classes] = class_names("no-wrap", @system_arguments[:classes])
+ @system_arguments[:tag] = "time-ago"
+ @system_arguments[:format] = "micro" if micro
+ @content = if micro
+ micro_time_ago(time)
+ else
+ time.in_time_zone.strftime("%b %-d, %Y")
+ end
+ end
+
+ def call
+ render(Primer::BaseComponent.new(**@system_arguments)) { @content }
+ end
+
+ private
+
+ def micro_time_ago(time)
+ seconds_ago = Time.current - time
+
+ if seconds_ago < 1.minute
+ "1m"
+ elsif seconds_ago >= 1.minute && seconds_ago < 1.hour
+ "#{(seconds_ago / 60).floor}m"
+ elsif seconds_ago >= 1.hour && seconds_ago < 1.day
+ "#{(seconds_ago / 60 / 60).floor}h"
+ elsif seconds_ago >= 1.day && seconds_ago < 1.year
+ "#{(seconds_ago / 60 / 60 / 24).floor}d"
+ elsif seconds_ago >= 1.year
+ "#{(seconds_ago / 60 / 60 / 24 / 365).floor}y"
+ end
+ end
+ end
+end

ADD
app/components/time_ago_component.ts
+import {TimeAgoElement} from '@github/time-elements'

ADD
demo/test/components/previews/time_ago_component_preview.rb
+class TimeAgoComponentPreview < ViewComponent::Preview
+ def default
+ render(Primer::TimeAgoComponent.new(time: Time.new))
+ end
+
+ def micro
+ render(Primer::TimeAgoComponent.new(time: Time.new, micro: true))
+ end
+end

MODIFY
docs/src/@primer/gatsby-theme-doctocat/nav.yml
docs/src/@primer/gatsby-theme-doctocat/nav.yml
url: /components/subhead
- title: Text
url: /components/text
+ - title: TimeAgo
+ url: /components/timeago
- title: TimelineItem
url: /components/timelineitem
- title: Tooltip

MODIFY
package.json
package.json
"rollup-plugin-terser": "^7.0.2",
"tslib": "^2.1.0",
"typescript": "^4.1.5"
+ },
+ "dependencies": {
+ "@github/time-elements": "^3.1.1"
}
}

MODIFY
static/statuses.json
static/statuses.json
-{"Primer::AvatarComponent":"beta","Primer::AvatarStackComponent":"alpha","Primer::BaseComponent":"alpha","Primer::BlankslateComponent":"alpha","Primer::BorderBoxComponent":"beta","Primer::BoxComponent":"stable","Primer::BreadcrumbComponent":"alpha","Primer::ButtonComponent":"alpha","Primer::ButtonGroupComponent":"alpha","Primer::ButtonMarketingComponent":"alpha","Primer::CounterComponent":"beta","Primer::DetailsComponent":"alpha","Primer::Dropdown::MenuComponent":"alpha","Primer::DropdownComponent":"alpha","Primer::DropdownMenuComponent":"deprecated","Primer::FlashComponent":"beta","Primer::FlexComponent":"alpha","Primer::FlexItemComponent":"alpha","Primer::HeadingComponent":"alpha","Primer::LabelComponent":"alpha","Primer::LayoutComponent":"alpha","Primer::LinkComponent":"beta","Primer::MarkdownComponent":"alpha","Primer::MenuComponent":"alpha","Primer::OcticonComponent":"beta","Primer::PopoverComponent":"alpha","Primer::ProgressBarComponent":"alpha","Primer::SpinnerComponent":"beta","Primer::StateComponent":"alpha","Primer::SubheadComponent":"alpha","Primer::TextComponent":"alpha","Primer::TimelineItemComponent":"alpha","Primer::TimelineItemComponent::BadgeComponent":"alpha","Primer::TooltipComponent":"alpha","Primer::TruncateComponent":"alpha","Primer::UnderlineNavComponent":"alpha"}
+{"Primer::AvatarComponent":"beta","Primer::AvatarStackComponent":"alpha","Primer::BaseComponent":"alpha","Primer::BlankslateComponent":"alpha","Primer::BorderBoxComponent":"beta","Primer::BoxComponent":"stable","Primer::BreadcrumbComponent":"alpha","Primer::ButtonComponent":"alpha","Primer::ButtonGroupComponent":"alpha","Primer::ButtonMarketingComponent":"alpha","Primer::CounterComponent":"beta","Primer::DetailsComponent":"alpha","Primer::Dropdown::MenuComponent":"alpha","Primer::DropdownComponent":"alpha","Primer::DropdownMenuComponent":"deprecated","Primer::FlashComponent":"beta","Primer::FlexComponent":"alpha","Primer::FlexItemComponent":"alpha","Primer::HeadingComponent":"alpha","Primer::LabelComponent":"alpha","Primer::LayoutComponent":"alpha","Primer::LinkComponent":"beta","Primer::MarkdownComponent":"alpha","Primer::MenuComponent":"alpha","Primer::OcticonComponent":"beta","Primer::PopoverComponent":"alpha","Primer::ProgressBarComponent":"alpha","Primer::SpinnerComponent":"beta","Primer::StateComponent":"alpha","Primer::SubheadComponent":"alpha","Primer::TextComponent":"alpha","Primer::TimeAgoComponent":"alpha","Primer::TimelineItemComponent":"alpha","Primer::TimelineItemComponent::BadgeComponent":"alpha","Primer::TooltipComponent":"alpha","Primer::TruncateComponent":"alpha","Primer::UnderlineNavComponent":"alpha"}

ADD
stories/primer/time_ago_component_stories.rb
+# frozen_string_literal: true
+
+class Primer::TimeAgoComponentStories < ViewComponent::Storybook::Stories
+ layout "storybook_preview"
+
+ story(:time_ago) do
+ controls do
+ time Time.zone.now
+ end
+ end
+end

MODIFY
test/components/component_test.rb
test/components/component_test.rb
@@ -49,6 +49,7 @@ class PrimerComponentTest < Minitest::Test
[Primer::SubheadComponent, { heading: "Foo" }, proc { |component| component.slot(:heading) { "Foo" } }],
[Primer::TextComponent, {}],
[Primer::TruncateComponent, {}],
+ [Primer::TimeAgoComponent, { time: Time.zone.now }],
[Primer::TimelineItemComponent, {}, proc { |component| component.body { "Foo" } }],
[Primer::TooltipComponent, { label: "More" }],
[Primer::UnderlineNavComponent, {}]

ADD
test/components/time_ago_component_test.js
+import {expect} from 'chai'
+import {componentTest} from './helpers.js'
+
+componentTest('time_ago_component', it => {
+
+ it('works', async page => {
+ await expect(page).to.have.selector('body')
+ const el = await page.$('body')
+ await expect(el).to.be.visible()
+ })
+
+})

ADD
test/components/time_ago_component_test.rb
+# frozen_string_literal: true
+
+require "test_helper"
+
+class PrimerTimeAgoComponentTest < Minitest::Test
+ include Primer::ComponentTestHelpers
+
+ def test_renders_time_ago_element
+ render_inline(Primer::TimeAgoComponent.new(time: Time.zone.now))
+
+ assert_selector("time-ago[datetime][class]", count: 1)
+ end
+
+ def reflects_datetime_attr_and_contents_based_on_date
+ render_inline(Primer::TimeAgoComponent.new(time: Time.utc(2021, 1, 1, 9, 10)))
+
+ assert_selector("time-ago[datetime=\"2021-01-01T09:10:00Z\"]", count: 1, text: "Jan 1, 2021")
+ end
+
+ def sets_contents_to_short_representation_with_micro_eq_true
+ Timecop.freeze("2021-01-30T15:00:00Z") do
+ render_inline(Primer::TimeAgoComponent.new(time: Time.utc(2021, 1, 1, 9, 10), micro: true))
+
+ assert_selector("time-ago[datetime=\"2021-01-01T09:10:00Z\"]", count: 1, text: "29d")
+ end
+ end
+
+ def returns_number_of_mins_ago_in_micro_format
+ Timecop.freeze("2021-01-01T09:50:00Z") do
+ render_inline(Primer::TimeAgoComponent.new(time: Time.utc(2021, 1, 1, 9, 10), micro: true))
+
+ assert_selector("time-ago[datetime=\"2021-01-01T09:10:00Z\"]", count: 1, text: "40m")
+ end
+ end
+
+ def returns_number_of_hours_ago_in_micro_format
+ Timecop.freeze("2021-01-01T15:00:00Z") do
+ render_inline(Primer::TimeAgoComponent.new(time: Time.utc(2021, 1, 1, 9, 10), micro: true))
+
+ assert_selector("time-ago[datetime=\"2021-01-01T09:10:00Z\"]", count: 1, text: "5h")
+ end
+ end
+
+ def returns_number_of_days_ago_in_micro_format
+ Timecop.freeze("2021-09-30T15:00:00Z") do
+ render_inline(Primer::TimeAgoComponent.new(time: Time.utc(2021, 1, 1, 9, 10), micro: true))
+
+ assert_selector("time-ago[datetime=\"2021-01-01T09:10:00Z\"]", count: 1, text: "d")
+ end
+ end
+
+ def returns_number_of_years_ago_in_micro_format
+ Timecop.freeze("2037-09-30T15:00:00Z") do
+ render_inline(Primer::TimeAgoComponent.new(time: Time.utc(2021, 1, 1, 9, 10), micro: true))
+
+ assert_selector("time-ago[datetime=\"2021-01-01T09:10:00Z\"]", count: 1, text: "16y")
+ end
+ end
+end

ADD
test/integration/time_ago_component_test.rb
+# frozen_string_literal: true
+
+require "application_system_test_case"
+
+class IntegrationTimeAgoComponentTest < ApplicationSystemTestCase
+ def test_render
+ with_preview(:default)
+
+ assert_selector("time-ago", text: "now")
+ end
+
+ def test_render_micro
+ with_preview(:micro)
+
+ assert_selector("time-ago", text: "1m")
+ end
+end

MODIFY
yarn.lock
yarn.lock
chalk "^2.0.0"
js-tokens "^4.0.0"
+"@github/time-elements@^3.1.1":
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/@github/time-elements/-/time-elements-3.1.1.tgz#b52be933a87a0b585a5df6fd2dea6ccda5d776b9"
+ integrity sha512-95TquDedxMpNb8BUrVIt9NEfdNBw4DRjdcECDPx4Ix6gkksI2npKRJBOV9XJMYjEIBo50iAiJzUuwL/fh3ptFw==
+
"@rollup/plugin-node-resolve@^11.2.0":
version "11.2.0"
resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.0.tgz#a5ab88c35bb7622d115f44984dee305112b6f714"

feat(TimeAgo): add TimeAgoComponent