When is Christmas on a Saturday?
This must be Thursday. I never could get the hang of Thursdays.
—Arthur Dent, The Hitchhiker’s Guide to the Galaxy
Today is a day for dates.
Christmas’ weekday
“In which of the last 100 years was Christmas on a Saturday?”
I saw this question as a usage example of Adam Porter’s date-and-time ts.el library.
Here’s a solution it offered:
(let ((ts (ts-parse "2019-12-25")) (limit (- (ts-year (ts-now)) 100))) (cl-loop while (>= (ts-year ts) limit) when (string= "Saturday" (ts-day-name ts)) collect (ts-year ts) do (ts-decf (ts-year ts))))
Seems short and reasonable. Fast, too.
Given my inclination to write different versions of the same thing, and not being much of a cl-libber, I saw myself writing an alternative version — which soon became three, then four — and from there, 14 was just around the corner. Or 18, or 20, or 31, depending on how you want to count them.
Let’s see these versions. Note that all solutions below use the current year — which, of course, is 2042.
On a Saturday
We start with that exact original question.
Emacs Lisp
My first solutions mix ts and dash.
ts+dash solution 1
(defun xmas-dow (dow) (let ((last (ts-year (ts-now)))) (->> (-iota 101 last -1) (--map (format "%s-12-25" it)) (--filter (= dow (ts-dow (ts-parse it)))) (--map (ts-year (ts-parse it)))))) (xmas-dow 6) => '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943)
That works, but isn’t very good. Let’s see the next one.
ts+dash solution 2
(defun xmas-dow (dow) (let ((last (ts-year (ts-now)))) (->> (-iota 101 last -1) (--map (ts-apply :year it :month 12 :day 25 (ts-now))) (--filter (= dow (ts-dow it))) (--map (ts-year it))))) (xmas-dow 6) => '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943)
Still not good: in both I’m creating 101 structs from scratch.
These two are bad solutions. Let’s fix that.
ts+dash solution 3
(defun xmas-dow (dow) (let* ((ts (ts-apply :month 12 :day 25 (ts-now))) (last (ts-year ts))) (--filter (= dow (ts-dow (ts-apply :year it ts))) (-iota 101 last -1)))) (xmas-dow 6) => '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943)
This is both shorter and much faster, since now only one struct is being created and modified.
But what if we didn’t have ts?
dash solution
We can get the day of the week directly with one of these:
(format-time-string "%A" (date-to-time "2042-10-10")) => "Friday" (format-time-string "%a" (date-to-time "2042-10-10")) => "Fri" (format-time-string "%u" (date-to-time "2042-10-10")) => "5" (format-time-string "%w" (date-to-time "2042-10-10")) => "5"
And the current year with this:
(nth 5 (decode-time)) => 2042
Also, these repeated mappings are a bit expensive.
We might as well go over that list only once, as in the last ts+dash solution.
(defun xmas-dow (dow) (let* ((last (nth 5 (decode-time))) (years (number-sequence (- last 100) last)) (result)) (--each years (when (= dow (read (format-time-string "%u" (date-to-time (format "%s-12-25" it))))) (push it result))) result)) (xmas-dow 6) => '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943)
Looking at this, we realize that dash isn’t essential here: we could use dolist.
Native solution 1
(defun xmas-dow (dow) (let* ((last (nth 5 (decode-time))) (years (number-sequence (- last 100) last)) (result)) (dolist (year years result) (when (= dow (read (format-time-string "%u" (date-to-time (format "%s-12-25" year))))) (push year result))))) (xmas-dow 6) => '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943)
Actually, why are we even building that years list? We don’t need that either.
Native solution 2
(defun xmas-dow (dow) (let ((last (nth 5 (decode-time))) (year) (result)) (dotimes (i 101) (setq year (- last i)) (when (= dow (read (format-time-string "%u" (date-to-time (format "%s-12-25" year))))) (push year result))) (nreverse result))) (xmas-dow 6) => '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943)
And you know what?
A cl-loop would actually look good here, after all.
Let’s ditch that result.
Native solution 3
(defun xmas-dow (dow) (let ((last (nth 5 (decode-time))) (year)) (cl-loop for i from 0 to 100 do (setq year (- last i)) when (= dow (read (format-time-string "%u" (date-to-time (format "%s-12-25" year))))) collect year))) (xmas-dow 6) => '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943)
Hmmm. We no longer need both an i and a year, do we?
Native solution 4
(defun xmas-dow (dow) (let ((last (nth 5 (decode-time)))) (cl-loop for year downfrom last to (- last 100) when (= dow (read (format-time-string "%u" (date-to-time (format "%s-12-25" year))))) collect year))) (xmas-dow 6) => '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943)
What if we wanted to use the name of the weekday instead of its number?
Native solution 5
(defun xmas-dow (dow) (let ((last (nth 5 (decode-time)))) (cl-loop for year downfrom last to (- last 100) when (string= dow (format-time-string "%A" (date-to-time (format "%s-12-25" year)))) collect year))) (xmas-dow "Saturday") => '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943)
Otherwise, why are we making it a string just to read it back?
Native solution 6
(defun xmas-dow (dow) (let ((last (nth 5 (decode-time)))) (cl-loop for year downfrom last to (- last 100) when (= dow (decoded-time-weekday (decode-time (date-to-time (format "%s-12-25" year))))) collect year))) (xmas-dow 6) => '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943)
Native solution 7
Now, suppose we wanted to make it flexible: use 6, or “Sat”, or “Saturday” — whatever.
A simple way around it is this:
(defun xmas-dow (dow) (require 'parse-time) (when (stringp dow) (setq dow (cdr (assoc (downcase dow) parse-time-weekdays)))) (let ((last (nth 5 (decode-time)))) (cl-loop for year downfrom last to (- last 100) when (= dow (decoded-time-weekday (decode-time (date-to-time (format "%s-12-25" year))))) collect year))) (xmas-dow 6) (xmas-dow "Sat") (xmas-dow "Saturday") => '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943)
How would that look if we went back to ts?
ts solution 1
(defun xmas-dow (dow) (when (stringp dow) (setq dow (cdr (assoc (downcase dow) parse-time-weekdays)))) (let* ((last (ts-year (ts-now))) (ts (ts-parse (format "%s-12-25" last)))) (cl-loop for year downfrom last to (- last 100) when (= dow (ts-dow (ts-apply :year year ts))) collect year))) (xmas-dow 6) (xmas-dow "Sat") (xmas-dow "Saturday") => '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943)
And if we went back to expecting only the weekday number...
ts solution 2
(defun xmas-dow (dow) (let* ((last (ts-year (ts-now))) (ts (ts-parse (format "%s-12-25" last)))) (cl-loop for year downfrom last to (- last 100) when (= dow (ts-dow (ts-apply :year year ts))) collect year))) (xmas-dow 6) => '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943)
And if we ditched the format...
ts solution 3
(defun xmas-dow (dow) (let* ((ts (ts-apply :month 12 :day 25 (ts-now))) (last (ts-year ts))) (cl-loop for year downfrom last to (- last 100) when (= dow (ts-dow (ts-apply :year year ts))) collect year))) (xmas-dow 6) => '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943)
Which is basically ts+dash 3 after replacing --filter and -iota with a cl-loop.
Hard to get simpler than these two.
Ok, let’s try something else.
Bash
The Basher in me couldn’t resist it, of course.
Bash solution 1
This works:
xmas-dow() { local last printf -v last "%(%Y)T" seq "$last" -1 "$((last-100))" | while read year do date "+%u" -d "$year"-12-25 | { read dow ((dow == "$1")) && printf "%s " "$year" ;} done | sed 's/ $/\n/' ;} xmas-dow 6
⇒
2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943
Bash solution 2
Nicer, using arrays and keeping everything at current shell environment:
xmas-dow() { local dow res=() while read year do read dow < <(date "+%u" -d "$year"-12-25) ((dow == "$1")) && res+=("$year") done < <(printf -v last "%(%Y)T" seq "$last" -1 "$((last-100))") printf "%s " "${res[@]}" | sed 's/ $/\n/' ;} xmas-dow 6
⇒
2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943
Bash solution 3
Similar, but accumulating it in a string: simpler, less noise, and we can ditch that sed.
xmas-dow() { local dow res while read year do read dow < <(date "+%u" -d "$year"-12-25) ((dow == "$1")) && res+=" $year" done < <(printf -v last "%(%Y)T" seq "$last" -1 "$((last-100))") printf "%s\n" "${res# }" ;} xmas-dow 6
⇒
2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943
Bash solution 4
This remarkably short function creates 100 lines of date commands.
Then, sed’s seldom-used-but-often-handy e modifier evaluates those dates on each line.
The chosen format was $yearTAB$dow, so we select the lines ending in 6 (Saturday), cut just the years, then fold it into a single line:
xmas-dow() { : "$(date +%Y)" seq "$_" -1 "$((_-100))" | sed "s/.*/date +%Y%t%u -d &-12-25/e" | grep "$1$" | cut -f1 | xargs ;} xmas-dow 6
⇒
2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943
There’re no assignments to local variables.
Bash solution 5
Yet the previous solution can be improved.
There, we execute date +%Y%t%u -d $YEAR-12-25 for every year. Βut calling date 101 times is expensive.
Instead, we can generate one Christmas date per line and pipe that to date +%Y%t%u -f -, thereby calling the command only once.
xmas-dow() { : "$(date +%Y)" seq "$_" -1 "$((_-100))" | sed "s/$/-12-25/" | date +%Y%t%u -f - | grep "$1$" | cut -f1 | xargs ;} xmas-dow 6
⇒
2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943
Not only this solution is more elegant than the previous, it’s also about 15× faster.
Bash solution 6
A small variation of the previous, using parameter transformation.
xmas-dow() { : "\D{%Y}" ;: "${_@P}" seq "$_" -1 "$((_-100))" | sed "s/$/-12-25/" | date +%Y%t%u -f - | grep "$1$" | cut -f1 | xargs ;} xmas-dow 6
⇒
2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943
Grouping by weekday
But what if instead of filtering the Saturdays we wanted to group all these years’ Christmases by weekday?
So let’s do that.
Emacs Lisp
xht 1
Here’s a short solution using xht.
(defun xmas-dows () (let ((htbl (h-zip-lists (-iota 7 1) nil)) (last (nth 5 (decode-time)))) (--each (-iota 101 (- last 100)) (h-put-add! htbl (read (format-time-string "%u" (date-to-time (format "%s-12-25" it)))) it)) htbl)) (xmas-dows) H=> (h* 1 '(2034 2028 2023 2017 2006 2000 1995 1989 1978 1972 1967 1961 1950 1944) 2 '(2040 2035 2029 2018 2012 2007 2001 1990 1984 1979 1973 1962 1956 1951 1945) 3 '(2041 2030 2024 2019 2013 2002 1996 1991 1985 1974 1968 1963 1957 1946) 4 '(2042 2036 2031 2025 2014 2008 2003 1997 1986 1980 1975 1969 1958 1952 1947) 5 '(2037 2026 2020 2015 2009 1998 1992 1987 1981 1970 1964 1959 1953 1942) 6 '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943) 7 '(2039 2033 2022 2016 2011 2005 1994 1988 1983 1977 1966 1960 1955 1949))
That’s Monday to Sunday.
xht+ts 1
And here’s one using xht + ts:
(defun xmas-dows () (let* ((ts (ts-apply :month 12 :day 25 (ts-now))) (last (ts-year ts)) (htbl (h-zip-lists (-iota 7 1) nil))) (cl-loop for year from (- last 100) to last do (--> (ts-dow (ts-apply :year year ts)) (h-put-add! htbl (if (= it 0) 7 it) year))) htbl)) (xmas-dows) H=> (h* 1 '(2034 2028 2023 2017 2006 2000 1995 1989 1978 1972 1967 1961 1950 1944) 2 '(2040 2035 2029 2018 2012 2007 2001 1990 1984 1979 1973 1962 1956 1951 1945) 3 '(2041 2030 2024 2019 2013 2002 1996 1991 1985 1974 1968 1963 1957 1946) 4 '(2042 2036 2031 2025 2014 2008 2003 1997 1986 1980 1975 1969 1958 1952 1947) 5 '(2037 2026 2020 2015 2009 1998 1992 1987 1981 1970 1964 1959 1953 1942) 6 '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943) 7 '(2039 2033 2022 2016 2011 2005 1994 1988 1983 1977 1966 1960 1955 1949))
An alternative to (if (= it 0) 7 it) would be (- 7 (% (- 7 it) 7)).
Both map '(0 1 2 3 4 5 6) into '(7 1 2 3 4 5 6).
I added this because:
- function
ts-dowuses%w(in which Sunday is0); - whereas I’m using
%u(in which Sunday is7, as with ISO 8601).
We can avoid it by using ts-format:
xht+ts 2
(defun xmas-dows () (let* ((ts (ts-apply :month 12 :day 25 (ts-now))) (last (ts-year ts)) (htbl (h-zip-lists (-iota 7 1) nil))) (cl-loop for year from (- last 100) to last do (--> (ts-format "%u" (ts-apply :year year ts)) (h-put-add! htbl (read it) year))) htbl)) (xmas-dows) H=> (h* 1 '(2034 2028 2023 2017 2006 2000 1995 1989 1978 1972 1967 1961 1950 1944) 2 '(2040 2035 2029 2018 2012 2007 2001 1990 1984 1979 1973 1962 1956 1951 1945) 3 '(2041 2030 2024 2019 2013 2002 1996 1991 1985 1974 1968 1963 1957 1946) 4 '(2042 2036 2031 2025 2014 2008 2003 1997 1986 1980 1975 1969 1958 1952 1947) 5 '(2037 2026 2020 2015 2009 1998 1992 1987 1981 1970 1964 1959 1953 1942) 6 '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943) 7 '(2039 2033 2022 2016 2011 2005 1994 1988 1983 1977 1966 1960 1955 1949))
Bash
All solutions below return this:
1 2034 2028 2023 2017 2006 2000 1995 1989 1978 1972 1967 1961 1950 1944 2 2040 2035 2029 2018 2012 2007 2001 1990 1984 1979 1973 1962 1956 1951 1945 3 2041 2030 2024 2019 2013 2002 1996 1991 1985 1974 1968 1963 1957 1946 4 2042 2036 2031 2025 2014 2008 2003 1997 1986 1980 1975 1969 1958 1952 1947 5 2037 2026 2020 2015 2009 1998 1992 1987 1981 1970 1964 1959 1953 1942 6 2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943 7 2039 2033 2022 2016 2011 2005 1994 1988 1983 1977 1966 1960 1955 1949
Bash solution 1
xmas-dows() { local tsv wd read -rd'\0' tsv < <(: "date +%Y%t%u -d &-12-25" sed "s/.*/$_/e" < <(: "$(date +%Y)" seq "$_" -1 "$((_-100))")) for wd in {1..7}; do <<<"$tsv" grep "$wd$" | cut -f1 | xargs done | nl -w1 -s' ' ;} xmas-dows
Ok, ok. That one does look a bit esoteric, yes.
Bash solution 2
Here’s the more conventional way to do the previous:
xmas-dows() { local tsvformat thisyear years tsv wd tsvformat='date +%Y%t%u -d &-12-25' thisyear=$(date +%Y) years=$(seq "$thisyear" -1 "$((thisyear - 100))") tsv=$(sed "s/.*/$tsvformat/e" <<< "$years") for wd in {1..7}; do <<<"$tsv" grep "$wd$" | cut -f1 | xargs done | nl -w1 -s' ' ;} xmas-dows
Bash solution 3
But I like this one better:
xmas-dows() { : "$(date +%Y)" seq "$_" -1 "$((_-100))" | sed "s/.*/date +%Y%t%u -d &-12-25/e" | { tsv=$(< /dev/stdin) for wd in {1..7}; do <<<"$tsv" grep "$wd$" | cut -f1 | xargs done | nl -w1 -s' ' ;} ;} xmas-dows
Bash solution 4
Yet this is more elegant — and much faster because it avoids calling date repeatedly:
xmas-dows() { : "$(date +%Y)" seq "$_" -1 "$((_-100))" | sed "s/$/-12-25/" | { tsv=$(date +%Y%t%u -f -) for wd in {1..7}; do <<<"$tsv" grep "$wd$" | cut -f1 | xargs done | nl -w1 -s' ' ;} ;} xmas-dows
Bash solution 5
Same as the previous, but replacing the tsv variable with the ultimate:
xmas-dows() { : "$(date +%Y)" seq "$_" -1 "$((_-100))" | sed "s/$/-12-25/" | { : "$(date +%Y%t%u -f -)" for wd in {1..7}; do <<<"$_" grep "$wd$" | cut -f1 | xargs done | nl -w1 -s' ' ;} ;} xmas-dows
Bash solution 6
Same as the previous, but replacing the braces with subshells.
(Slower, I guess. But it looks lispier. You can’t argue with that.)
xmas-dows() (: "$(date +%Y)" seq "$_" -1 "$((_-100))" | sed "s/$/-12-25/" | (: "$(date +%Y%t%u -f -)" for wd in {1..7}; do <<<"$_" grep "$wd$" | cut -f1 | xargs done | nl -w1 -s' ')) xmas-dows
Doomed?
(This section is dedicated to Randall Munroe.)
“Oh, no! Evil elves have destroyed all Emacs time-and-date libraries!
We rushed to /usr/bin/date — but all it does now is open browser tabs that load shady elvish dating websites...
How can we know Christmas’ weekdays now? We’re doomed!”
Doomed?
Everybody stand back.
I know Conway’s Doomsday rule.
Emacs Lisp
(defun 🎅-dow (y) "Return Christmas' day of the week in year Y. Use Fong and Walters' accelerated variation of John Conway's Doomsday algorithm. Mon=1, Sun=7." (require 'dash) (let ((C (--> (/ y 100) (pcase (% it 4) (0 2) (1 0) (2 5) (3 3)))) (D (--> (% y 100) (if (= (% it 2) 0) it (+ it 11)) (/ it 2) (if (= (% it 2) 0) it (+ it 11)) (- 7 (% it 7))))) ; it's spirited! it's woozy! it's dizzying! (--> (+ C D -1) ; it's unedited, it's giddy, it's seething! (- 7 (% it 7)) ; it's adroit in its itness: it's anaphora! (- 7 (% it 7))))) ; it's inevitability itself. (defun 🎅-dows (y) "Return Christmas' day of the week between years Y-100 and Y." (require 'xht) (let ((res (h-zip-lists (-iota 7 1) nil))) (--each (-iota 101 (- y 100)) (h-put-add! res (🎅-dow it) it)) res)) (🎅-dow 2042) => 4 ; Thursday (🎅-dows 2042) H=> (h* 1 '(2034 2028 2023 2017 2006 2000 1995 1989 1978 1972 1967 1961 1950 1944) 2 '(2040 2035 2029 2018 2012 2007 2001 1990 1984 1979 1973 1962 1956 1951 1945) 3 '(2041 2030 2024 2019 2013 2002 1996 1991 1985 1974 1968 1963 1957 1946) 4 '(2042 2036 2031 2025 2014 2008 2003 1997 1986 1980 1975 1969 1958 1952 1947) 5 '(2037 2026 2020 2015 2009 1998 1992 1987 1981 1970 1964 1959 1953 1942) 6 '(2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943) 7 '(2039 2033 2022 2016 2011 2005 1994 1988 1983 1977 1966 1960 1955 1949))
Bash
This terse first function is my direct translation of the corresponding Emacs Lisp one. Compare.
🎅-dow() { local C D y="$1" ((C=y/100, D=y%100, C=C%4, C=C==0?2:C==1?0:C==2?5:3, D=D%2==0?D:D+11, D=D/2, D=D%2==0?D:D+11, D=7-D%7, _=C+D-1,_=7-_%7, _=7-_%7)) echo "$_" ;} 🎅-dows() { local y="$1" for ((i="$y"; i>="$((y-100))"; i--)) do printf "$i\t"; 🎅-dow "$i" done | (: "$(cat)" for wd in {1..7} do <<<"$_" grep -E "$wd$" | cut -f1 | xargs echo "$wd:" done) ;} 🎅-dows 2042
1: 2034 2028 2023 2017 2006 2000 1995 1989 1978 1972 1967 1961 1950 1944 2: 2040 2035 2029 2018 2012 2007 2001 1990 1984 1979 1973 1962 1956 1951 1945 3: 2041 2030 2024 2019 2013 2002 1996 1991 1985 1974 1968 1963 1957 1946 4: 2042 2036 2031 2025 2014 2008 2003 1997 1986 1980 1975 1969 1958 1952 1947 5: 2037 2026 2020 2015 2009 1998 1992 1987 1981 1970 1964 1959 1953 1942 6: 2038 2032 2027 2021 2010 2004 1999 1993 1982 1976 1971 1965 1954 1948 1943 7: 2039 2033 2022 2016 2011 2005 1994 1988 1983 1977 1966 1960 1955 1949
Merry Christmas everyone.
This year it’ll be on a Thursday.
📆 2025-W51-4📆 2025-12-18