Relative dates, to your taste
Awhile is an Emacs package.
FromNow is a Bash package.
So I wrote something that calculates a time difference. I mean, it’s a simple subtraction — how hard could that be?
The idea
The idea first came when looking at my git commit history, which shows fuzzy elapsed time. It looks like this:
42 seconds ago 10 minutes ago 1 hour ago 2 days ago 3 weeks ago 8 months ago 11 months ago 1 year ago 1 year, 4 months ago 2 years, 7 months ago
(This can be shown with git log --date=relative, or by using the %ar formatter (author, relative) for --pretty.)
Interesting, I thought. It shows only the highest unit, except when it’s more than one year, in which case it may show two.
I wanted to have this out of git, as a filter — but one that allowed me to tweak the format. The moreutils package has a binary that does something similar, so I had a look. Piping dates to ts -r produces things such as:
12h11m ago 334d12h ago 42d9h from now
So unlike git, it always uses two units, which I liked. And it offers a short format, which I also liked.
On the other hand, it doesn’t have the long format. And the highest unit is “day” — no weeks, no months, no year. And the accepted formats for input date seemed a bit restricted.
I thought I could write something more flexible.
The packages
I wanted to control:
- Which units could be used
- The maximum number of units used
- Whether the format is long (English) or short (one-letter units)
- And maybe a few other output options, easier to parse
And I wanted to pipe dates to it.
So I wrote a function.
FromNow
It started with modest 30 lines of Bash that actually worked.
# Version 0.0.1, as a single function fromnow0() { local now inp sec fmt div val uni now=$(date +%s) inp=$(date +%s -d "${1:-@$now}") sec=$((now-inp)) if ((sec==0)); then echo "now"; return 0 elif ((sec <0)) then ((sec=-sec)) : "from now" else : "ago" fi; fmt="%s %s $_\n" ((div= (_=31557600, sec >= _) ? _ : (_= 2629800, sec >= _) ? _ : (_= 604800, sec >= _) ? _ : (_= 86400, sec >= _) ? _ : (_= 3600, sec >= _) ? _ : (_= 60, sec >= _) ? _ : (_= 1, sec >= _) ? _ : 0, _= sec/div, val= 2*(sec%div) < div ? _ : _+1)) case "$div" in (31557600) : year ;; ( 2629800) : month ;; ( 604800) : week ;; ( 86400) : day ;; ( 3600) : hour ;; ( 60) : minute ;; ( 1) : second ;; esac; uni="$_"; ((val>1)) && uni+="s" printf "$fmt" "$val" "$uni" ;}
The above could already output the same as the current fromnow -c1 <somedate>.
Then I thought: “Let’s do the git thing of two units when more than one year.”
So this 42-line version could do that:
# Version 0.0.2, as a single function fromnow0() { local now inp sec fmt div val uni \ _s=1 _m=60 _h=3600 _D=86400 _W=604800 _M=2629800 _Y=31557600 now=$(date +%s) inp=$(date +%s -d "${1:-@$now}") sec=$((now-inp)) if ((sec==0)); then echo "now"; return 0 elif ((sec <0)) then ((sec=-sec)) : "from now" else : "ago" fi; fmt="%s %s $_\n" ((div= sec >= _Y ? _Y : sec >= _M ? _M : sec >= _W ? _W : sec >= _D ? _D : sec >= _h ? _h : sec >= _m ? _m : sec >= _s ? _s : 0, _= sec/div, val= 2*(sec%div) < div ? _ : _+1)) case "$div" in "$_Y") : year ;; "$_M") : month ;; "$_W") : week ;; "$_D") : day ;; "$_h") : hour ;; "$_m") : minute ;; "$_s") : second ;; esac; uni="$_"; ((val>1)) && uni+="s" if [[ "${uni:0:1}" == y ]] then local y m ((_=_Y, y=sec/_, sec%=_, div=_M, _= sec/div, m= 2*(sec%div) < div ? _ : _+1, y==1)) && : "" || : s if ((m==0)) then uni="year$_"; val="$y" # Tiny hack to reuse variables: else val=$(printf "%s," "$y year$_") ((m==1)) && : "" || : s uni=$(printf "%s" "$m month$_") fi; fi printf "$fmt" "$val" "$uni" ;}
It was at this point that I thought that hardcoding an exception that would only work for “years” seemed a bit silly: the -c flag was conceived.
Then it grew to a 440-line file full of docstrings and options and composable functions and whatnot.
It has all the options I wanted, and I was happy to finally dogfood my Bash docstrings, into which I even added some Org tables.
To test it, I went back to git.
The function below gives me all the commit dates as Unix timestamps (seconds since the Epoch), one per line:
git2epoch() { git log --pretty="format:@%at" ;}
which I can now feed to FromNow to get either long and complete descriptions:
git2epoch | fromnow | less
2 weeks, 5 days, 10 hours, 42 minutes, 56 seconds ago 2 weeks, 5 days, 17 hours, 33 minutes, 32 seconds ago 8 months, 1 week, 1 day, 20 hours, 34 minutes, 19 seconds ago 1 year, 7 months, 3 weeks, 2 days, 17 hours, 43 seconds ago
or long and fuzzy descriptions with no more than two items (rounding the smallest unit’s value if needed):
git2epoch | fromnow -c2 | less
2 weeks, 5 days ago 2 weeks, 6 days ago 8 months, 1 week ago 1 year, 8 months ago
or the same — but brief instead of long:
git2epoch | fromnow -bc2 | less
-2W5D -2W6D -8M1W -1Y8M
or the same — but without using months or years:
git2epoch | fromnow -bc2 -uWDhms | less
-2W5D -2W6D -36W9h -86W31m
or a handful of others.
Having done that, I thought I might as well translate it and adapt it to Emacs Lisp.
Awhile
Adam Porter’s ts.el library offers all sorts of date-and-time operations, with attention given to performance.
“If there’s one place where this has already been implemented, it’s there”, I reckoned.
So I went to check the usage examples.
There were, indeed, examples for time differences, in two formats — but I soon realized it wouldn’t work for me.
- First, “month” and “week” weren’t available as possible units, and I wanted to have those.
- Second, there seemed to be no way to choose which units to include or exclude.
- Third, there seemed to be no way to choose the maximum number of units to use.
These three were essential to what I wanted, so I was still at square one.
I found a few other barriers:
- Fourth, time difference only works in one direction, returning “0 seconds” otherwise (could be worked around).
- Fifth, no “ago” or “from now” (could be added).
- Sixth, pluralization:
(ts-human-format-duration (ts-difference (ts-now) (ts-adjust 'year -1 'day -1 'hour -1 'second -1 (ts-now)))) => "1 years, 1 days, 1 hours, 1 seconds"
So it seemed I wouldn’t be able to get a leg up from ts.el.
Ok, but maybe native libraries already had what I wanted?
So I dived into Emacs’ fascinating maze of time-related functions. You can decode-time, and encode-time, and time-convert. You can submit a string to parse-time-string (ignores TZ, liberal), or iso8601-parse (ignores TZ, stricter), or format-time-string (a lot like /usr/bin/date). You can time-subtract inputs if they are in that peculiar format of current-time, which can be converted to seconds since the Epoch with time-to-seconds. You can days-to-time, whose docstring informs us isn’t quite the inverse of time-to-days (not the same as time-to-number-of-days, mind you), and which shouldn’t be confused with the similar-sounding date-to-time, whose lovely comment above the function educates us that:
;; `parse-time-string' isn't sufficiently general or robust. It fails ;; to grok some of the formats that timezone does (e.g. dodgy ;; post-2000 stuff from some Elms) and either fails or returns bogus ;; values. timezone-make-date-arpa-standard should help.
Thankfully, finicky as my seconds-distribution demands seemed to be, grokking dodgy stuff from some Elms was not a hard requirement — so I moved on, meeting date-to-day, seconds-to-time, safe-date-to-time (dizzy yet?), time-since — oh, this one sounds useful! But it only accepts one argument, and I wanted the flexibility to pass “a different now”. (Maybe that function could accept an (&optional now)? Or maybe there could be some with-current-time macro to wrap around time operations?)
I then saw format-seconds — which seemed promising. It had %x and %y options to omit values when they’re zeros. And it took care of pluralization.
Alas, it also didn’t have “month” or “week” as units. And I found no generic way to remove zero values from the middle:
(format-seconds "%Y, %D, %H, %M, %S" 86460) => "0 years, 1 day, 0 hours, 1 minute, 0 seconds" (format-seconds "%x%Y, %D, %H, %M, %S%z" 86460) => "1 day, 0 hours, 1 minute,"
(By the way, ts.el removed all zeros automatically. I liked that.)
And that trailing comma would have to be dealt with (minor).
And it didn’t round up:
(format-seconds "%D, %H, %M, %S" 86502) => "1 day, 0 hours, 1 minute, 42 seconds" (format-seconds "%D, %H, %M" 86502) => "1 day, 0 hours, 1 minute"
So format-seconds couldn’t help me either.
I then stumbled on seconds-to-string, which does something I hadn’t thought about: automatically pick only one unit and format the value as a float:
(seconds-to-string 42) => "42.00s" (seconds-to-string 424) => "7.07m" (seconds-to-string 24242) => "6.73h" (seconds-to-string 424242) => "4.91d" (seconds-to-string 42424242) => "1.34y"
Alas, it also didn’t have “month” or “week” as units.
But I liked this output format. On the other hand, making Awhile restrict it to two units already accomplishes something similar:
(awhile-abs 42 :c 2 :d 'ss) => "42s" (awhile-abs 424 :c 2 :d 'ss) => "7m4s" (awhile-abs 24242 :c 2 :d 'ss) => "6h44m" (awhile-abs 424242 :c 2 :d 'ss) => "4D22h" (awhile-abs 42424242 :c 2 :d 'ss) => "1Y4M" (awhile-abs 42 :c 2) => "42 seconds" (awhile-abs 424 :c 2) => "7 minutes, 4 seconds" (awhile-abs 24242 :c 2) => "6 hours, 44 minutes" (awhile-abs 424242 :c 2) => "4 days, 22 hours" (awhile-abs 42424242 :c 2) => "1 year, 4 months" (awhile-now 0 :n 42 :c 2) => "42 seconds ago" (awhile-now 0 :n 424 :c 2) => "7 minutes, 4 seconds ago" (awhile-now 0 :n 24242 :c 2) => "6 hours, 44 minutes ago" (awhile-now 0 :n 424242 :c 2) => "4 days, 22 hours ago" (awhile-now 0 :n 42424242 :c 2) => "1 year, 4 months ago"
And this, I argue, is more intuitive than fractional units: “44 minutes” is more immediately graspable than “.73 hours”.
After all that, I concluded I’d have to start from the beginning:
- first, produce seconds;
- then carefully distribute them into units according to constraints;
- then use that to produce different output formats.
Production of seconds is the easy part, and our good dodgy-stuff-from-some-Elms–grokking function was actually used, after all.
Here’s Awhile producing seconds:
(awhile-secs "2042-04-02T04:42:42+02") => 2280019362 ;; But this input^ is conventionally-formatted — no dodginess here.
So let’s talk about the units.
The units
Our units of temporal measurement, from seconds on up to months, are so complicated, asymmetrical and disjunctive so as to make coherent mental reckoning in time all but impossible. Indeed, had some tyrannical god contrived to enslave our minds to time, to make it all but impossible for us to escape subjection to sodden routines and unpleasant surprises, he could hardly have done better than handing down our present system. It is like a set of trapezoidal building blocks, with no vertical or horizontal surfaces, like a language in which the simplest thought demands ornate constructions, useless particles and lengthy circumlocutions. Unlike the more successful patterns of language and science, which enable us to face experience boldly or at least level-headedly, our system of temporal calculation silently and persistently encourages our terror of time.
... It is as though architects had to measure length in feet, width in meters and height in ells; as though basic instruction manuals demanded a knowledge of five different languages. It is no wonder then that we often look into our own immediate past or future, last Tuesday or a week from Sunday, with feelings of helpless confusion.
—Robert Grudin, ‘Time and the Art of Living’.
This quote comes straight from Coreutils’ Info anchor “Date input formats”, just before they explain the syntax for things like:
date -d "last Tuesday next month 5am" date -d "Sunday a week"
So. You might have noticed a certain pattern:
moreutils’tsdidn’t have week, month, or yearts.eldidn’t have week or monthformat-secondsdidn’t have week or monthseconds-to-stringdidn’t have week or month
Yet git had all of these, and flandrew seemed to be stubbornly insisting on having them, too.
What’s going on?
Well:
- A year has 365 days, except when it has 366, so that’s a problem. Choosing 365-day years, as did #2 and #3, has the advantage of getting it exactly right when the duration is small — but the disadvantage of higher and higher error as the duration goes up.
- A month varies from 28 to 31 days, so it’s a mess that people avoid.
- A week is precisely seven days — but whole weeks don’t fit into a year of any size¹, whereas days do fit into 365- or 366-day ones. I believe this is one reason why they’re often missing as an option.
¹ except when they do
If instead of the usual month-based ISO calendar you use an ISO week calendar, 82.25% of your years will have exactly 52 weeks, whereas the other 17.75% will have exactly 53. So most years there have 364 days; and some have 371.
I opted for:
- the 4-year average year of 365 days, 6 hours — as did #4;
- the 4-year average month of 30 days, 10 hours, and 30 minutes — twelve of which exactly fit into the average year.
So my units don’t depend on context. When you see “1 year, 1 month from now” in Awhile’s output, it doesn’t matter when “now” is — the amount is the same:
(awhile-rev "1 year, 1 month" :u"Dhms") => "395 days, 16 hours, 30 minutes" (awhile-rev "1 year, 1 month" :u"Dhms" :d 'ss) => "395D16h30m" (awhile-rev "1 year, 1 month" :u"WDhms" :d 'ss) => "56W3D16h30m" (awhile-rev "1 year, 1 month" :u"WDhms" :d 'lv) => '(nil 0 0 56 3 16 30 0) (awhile-rev "1 year, 1 month" :u"s" :d 'ss) => "34187400s" (awhile-rev "1 year, 1 month from now") => '(+ . 34187400) ; this many seconds
I offer these units as options because many like them, and those who don’t can easily exclude them.
Here’s an idea:
- When fuzzy results are desirable, exclude no unit, but restrict to showing no more than two units.
- Otherwise, exclude months — and maybe also years and/or weeks, depending on your taste.
But it’s up to you. It’s flexible. That was the point.
Yet one thing still bothered me. Something was missing.
The ISO 8601 stuff that people don’t use
ISO 8601 has standards for representing durations and time intervals — which I’ve known for years, but must have seen used in the wild maybe... twice, give or take? The usual explanation applies: people don’t know it exists, so they don’t use it.
So we keep seeing monstrosities such as unpadded ambiguous middle-endian dates separated by hyphen-minuses:
4/11/41 - 5/12/42
which in turn raises important questions:
- Is that April to May, or November to December?
- Is that around WWII, or here in the future?
- Why don’t FS Micronesia, Philippines, Togo, United States, and very few others abandon this insane
MDYformat?
A much saner format for simple date is ISO 8601, as xkcd also duly noted in a public service announcement.
(I was baffled when later he used middle-endian dates in xkcd’s site header to announce his book’s tour. What happened? Distraction? Updated by someone else? Can’t be fear of misunderstanding: what are the chances that his readership wouldn’t figure out an ISO 8601 date?)
When it comes to intervals, the standard suggests one of these valid options:
2041-04-11/P1Y1M1D P1Y1M1D/2042-05-12 2041-04-11/2042-05-12
The duration thing depends on context — the problem I’ve just talked about. That is, P1Y can refer to 365 or 366 days, depending on where you start and in what direction. Month is worse. But intervals have context — they’re unambiguous.
So I wanted Awhile to be able to handle these as input.
Thankfully, the heavy lifting had already been done, because Emacs has a native iso8601.el library.
Sleep well at night, reassured that simple regular expressions take care of your duration needs:
iso8601--duration-match => "\ \\(?:P\\(?:[0-9]+Y\\)?\\(?:[0-9]+M\\)?\\(?:[0-9]+D\\)?\\(?:T\\(?:[0-9]+H\\)?\\(?: [0-9]+M\\)?\\(?:[0-9]+S\\)?\\)?\\)\\|\\(?:P\\(?:[0-9]+\\)W\\)\\|\\(?:P\\(?:\\(?:? :\\(?:?:[+-]?[0-9][0-9][0-9][0-9]\\)\\)\\|\\(?:?:\\(?:?:[+-]?[0-9][0-9][0-9][0-9] \\)-?\\(?:?:[0-9][0-9]\\)-?\\(?:?:[0-9][0-9]\\)\\)\\|\\(?:?:\\(?:?:[+-]?[0-9][0-9 ][0-9][0-9]\\)-\\(?:?:[0-9][0-9]\\)\\)\\|\\(?:?:--\\(?:?:[0-9][0-9]\\)-?\\(?:?:[0 -9][0-9]\\)\\)\\|\\(?:?:---?\\(?:?:[0-9][0-9]\\)\\)\\|\\(?:?:\\(?:?:[+-]?[0-9][0- 9][0-9][0-9]\\)-?W\\(?:?:[0-9][0-9]\\)-?\\(?:?:[0-9]\\)?\\)\\|\\(?:?:\\(?:?:[+-]? [0-9][0-9][0-9][0-9]\\)-?\\(?:?:[0-9][0-9][0-9]\\)\\)\\)\\(?:?:T\\(?:\\(?:?:[0-9] [0-9]\\):?\\(?:?:[0-9][0-9]\\)?:?\\(?:?:[0-9][0-9]\\)?[.,]?\\(?:?:[0-9]*\\)\\)\\ (?:\\(?:Z\\|\\(?:[+-]\\)\\(?:[0-9][0-9]\\):?\\(?:[0-9][0-9]\\)?\\)\\)?\\)?\\)"
That’s a single 805-character line, which I split for readability(?).
So Awhile can do it. Here’s one of six functions that deal with these:
(awhile-now-ival "2041-04-11/P1Y1M1D") => "1 year, 1 month, 7 hours, 30 minutes from now"
(The output uses Awhile’s constant year and month, remember?)
If instead you pass a duration, it uses the current time (now) as the reference.
(awhile-now-durn "P1Y1M1D") => "1 year, 1 month, 1 day, 7 hours, 30 minutes from now"
The extra actual day you see in this result compared to the previous is because we’re in December, so ISO 8601’s duration’s +1M spans a 31-day month, whereas the previous +1M does it from a 30-day November.
Awhile outputs combinations of well-defined units that you can take to the bank and cash out for seconds. (Well, Earth seconds, at low speeds, ignoring leap seconds, and...)
It also takes care of leap years.
For example:
(let ((more (awhile-secs-durn "P2Y")) (less (awhile-secs-durn "P2Y" t))) (list more less (awhile-abs (+ less more)))) => '(63072000 -63158400 "1 day")
ISO8601’s duration’s +2Y from the Epoch is not the same as -2Y from the Epoch (1970-01-01T00:00:00+00):
- To get to
1972-01-01, you add730days. - To get to
1968-01-01, you subtract731days: it includes an extra day,1968-02-29.
You can see it in weeks or days instead:
(awhile-now-durn "P1Y1M1D" nil :u"D") => "397 days from now" (awhile-now-durn "P1Y1M1D" nil :u"WD") => "56 weeks, 5 days from now" (awhile-now-ival "2041-04-11/P1Y1M1D" :u"WD") => "56 weeks, 4 days from now" (awhile-now-ival "2041-04-11/P1Y1M1D" :u"Dhms") => "396 days from now" (awhile-now-ival "2041-04-11/P1Y1M1D" :u"Dhms" :S 0) => "396 days" (awhile-now-ival "2041-04-11/P1Y1M1D" :u"Dhms" :d 'ss) => "+396D"
The order matters:
(awhile-now-ival "2041-04-11/P1D") => "1 day from now" (awhile-now-ival "P1D/2041-04-11") => "1 day ago"
which is how I found an interesting bug when writing these functions:
;; So far so good: (awhile-now-ival "2042-12-31/P1M" :u"Dh") => "31 days from now" (awhile-now-ival "2042-01-01/P1M" :u"Dh") => "31 days from now" (awhile-now-ival "P1M/2042-12-31" :u"Dh") => "30 days ago" ;; Nope: (awhile-now-ival "P1M/2042-01-01" :u"Dh") => "334 days from now"
The bug
The bug is upstream, in iso8601.el.
It seems to happen:
- with prepended durations in intervals,
- where one of the units is “month”,
- where its amount isn’t divisible by 12,
- where there’d be a change in the year.
Look:
;; Right. One month is added and the year changes: (iso8601-parse-interval "2042-12-31T00Z/P1M") => '((0 0 0 31 12 2042 nil nil 0) (0 0 0 31 1 2043 nil nil 0) (0 0 0 0 1 0 nil -1 nil)) ;; Wrong. One month is subtracted and the year doesn't change: (iso8601-parse-interval "P1M/2042-01-01T00Z") => '((0 0 0 1 12 2042 nil nil 0) (0 0 0 1 1 2042 nil nil 0) (0 0 0 0 1 0 nil -1 nil)) ;; Should be: => '((0 0 0 1 12 2041 nil nil 0) (0 0 0 1 1 2042 nil nil 0) (0 0 0 0 1 0 nil -1 nil))
Smells like some simple off-by-one issue with something mod 12. Maybe.
By comparison, ts.el, which also does this sort of “by-slot adjustment”, gets it right:
(ts-format "%F" (ts-adjust 'month -1 (ts-parse "2042-01-01T00Z"))) => "2041-12-01"
The examples
You saw above some usage examples of FromNow. You can see many more with:
chmod +x dev/examples.sh dev/examples.sh # quit with q
I wrote hundreds of examples of Awhile usage.
You can see them in the link right above this line.
Or with M-x awhile-see-readme.
Or, after democratizing the library:
- in a Help or Helpful buffer for any
awhilefunction, or - all of them inside Org trees with M-x
democratize-full-overview-library.
Enjoy!
📆 2025-W51-4📆 2025-12-18