Moving Relative Date Text Client-Side
Sun Mar 12 10:57:29 EDT 2023
One of my main goals in the slow-moving OpenNTF home-page revamp project I'm doing (which I recently moved to a public repo, by the way) is to, like on this blog, keep things extremely simple. There's almost no JavaScript - just Hotwire Turbo so far - and the UI is done with very-low-key JSP pages and tags.
The Library
This also extends to the server side, where I use the XPages Jakarta EE project as the baseline but otherwise don't yet have any other dependencies to distribute. I have had a few Java dependencies kept as JARs within the project, though, and they're always a bit annoying. The other day, when Designer died again trying to sync one of them to the ODP, I decided to try to remove PrettyTime in favor of something slimmer.
PrettyTime is a handy little library that does the job of turning a moment in time into something human-friendly relative to the current time. OpenNTF uses this for, for example, the list of recent releases, where it'll say things like "19 hours ago" or "5 months ago". Technically the same information as if it showed the raw date, but it's a little nicer. It's also svelte, with the JAR coming in at about 160k. Still, nice as it is, it's a binary blob and a third-party dependency, so it was on the chopping block.
The Strategy
My antipathy for using JavaScript isn't so much about an objection to the language itself but to the sort of bloated monstosities it gives rise to. Using it in the "old" way - progressive enhancement - is great. Same goes for Web Components: I think they're the right tool a lot less frequently than a lot of JavaScript UI toolkits do, but they have their place, and this is one such place.
What I want is a way to send a "raw" ISO time value to the client and have it actually display something nice. Conveniently, just the other week, Stephan Wissel tipped me off to the existence of the Intl
object in JavaScript, which handles the fiddly business of time formating, pluralization, and other locale-related needs on behalf of the user. Using this, I could write code that translates an ISO date into something friendly without also then being on the hook for coming up with translations for any future non-English languages that the OpenNTF app may support.
The Component
In the original form, when I was using PrettyTime, the code in JSP to emit relative times tended to look like this:
1 | <c:out value="${temporalBean.timeAgo(release.releaseDate)}"/> |
I probably could have turned that into a JSP tag like <t:timeAgo value="${release.releaseDate}"/>
, but it was already svelte enough in the normal case. Either way, this already looks very close to what it would be with a web component, just happening server-side instead of on the client.
As it happens, Web Components aren't actually terribly difficult to write. A lot of JS toolkits optimize for the complex case - Shadow DOM and all that - but something like this can be readily written by hand. It can start with some normal JavaScript (since I'm writing this in Designer, I made this a File resource, since then the JS editor doesn't die on modern syntax):
1 2 3 4 5 6 7 8 9 10 11 | class TimeAgo extends HTMLElement { constructor() { super(); } connectedCallback() { /* Do the formatting */ } } customElements.define("time-ago", TimeAgo); |
Put that in a .js file included in the page and then you can use <time-ago/>
at will! It won't do anything yet, but it'll be legal.
While the Intl
library will help with this task, it doesn't inherently have all the same functionality as PrettyTime. Fortunately, there's a pretty reasonable idiom that basically everybody who has sought to solve this problem has ended up on. Plugging that into our component, we can get this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | class TimeAgo extends HTMLElement { static units = { year: 24 * 60 * 60 * 1000 * 365, month: 24 * 60 * 60 * 1000 * 365 / 12, day: 24 * 60 * 60 * 1000, hour: 60 * 60 * 1000, minute: 60 * 1000, second: 1000 }; static relativeFormat = new Intl.RelativeTimeFormat(document.documentElement.lang); static dateTimeFormat = new Intl.DateTimeFormat(document.documentElement.lang, { dateStyle: 'short', timeStyle: 'short' }); static dateFormat = new Intl.DateTimeFormat(document.documentElement.lang, { dateStyle: 'medium' }); constructor() { super(); } connectedCallback() { let date = new Date(this.getAttribute("value")); this.innerText = this._relativize(date); if (this.getAttribute("value").indexOf("T") > -1) { this.title = TimeAgo.dateTimeFormat.format(date); } else { this.title = TimeAgo.dateFormat.format(date) } } _relativize(d1) { var elapsed = d1 - new Date(); for (var u in TimeAgo.units) { if (Math.abs(elapsed) > TimeAgo.units[u] || u == 'second') { return TimeAgo.relativeFormat.format(Math.round(elapsed / TimeAgo.units[u]), u) } } return TimeAgo.dateTimeFormat.format(d1); } } customElements.define("time-ago", TimeAgo); |
The document.documentElement.lang
bit there means that it'll hew to being the professed language of the page - that could also use the default language of the user, but it'd probably be disconcerting if only the times were in one's native language.
With that in place, I could replace the server-side version with the Web Component:
1 | <time-ago value="${release.releaseDate}" /> |
When that loads on the page, the browser will display basically the same thing as before, but the client side is doing the lifting here.
It's not perfect yet, though. Ideally, I'd want this to extend a standard element like <time/>
, so you'd be able to write like <time is="time-ago" datetime="${release.releaseDate}">${release.releaseDate}</span>
- that way, if the browser doesn't have JavaScript enabled or something goes awry, there'd at least be an ISO date visible on the page. That'd be the real progressive-enhancement path. When I tried that route, I wasn't able to properly remove the original text from the DOM like I'd like, but that's presumably something that I could do with some more investigation.
In the mean time, I'm pretty pleased with the switch. One fewer binary blob in the NSF, a bit of new knowledge of a browser technology, and the app's code remains clean and meaningful. I'll take it.