The Dude
The Dude

Reputation: 315

How to count days excluding weekends and holidays in Emacs calendar

In Emacs calendar, one can count days between two dates (including both the start and the end date) using the M-= which runs the command calendar-count-days-region. How can I count days excluding the weekends (Saturday and Sunday) and if defined holidays coming from the variables: holiday-general-holidays and holiday-local-holidays?

Upvotes: 4

Views: 1298

Answers (1)

Carl Groner
Carl Groner

Reputation: 4359

I think this essentially breaks down into three parts:

  • Count the days in a region
  • subtract the weekend days
  • subtract the holidays

Emacs already has the first part covered with M-= (calendar-count-days-region), so let's take a look at that function.

Helpful, but unfortunately it reads the buffer and sends the output directly. Let's make a generalized version which takes start and end date parameters and returns the number of days instead of printing them:

(defun my-calendar-count-days(d1 d2)
  (let* ((days (- (calendar-absolute-from-gregorian d1)
                  (calendar-absolute-from-gregorian d2)))
         (days (1+ (if (> days 0) days (- days)))))
    days))

This is pretty much just a copy of the calendar-count-days-region function, but without the buffer reading & writing stuff. Some tests:

(ert-deftest test-count-days ()
  "Test my-calendar-count-days function"
  (should (equal (my-calendar-count-days '(5 1 2014) '(5 31 2014)) 31))
  (should (equal (my-calendar-count-days '(12 29 2013) '(1 4 2014)) 7))
  (should (equal (my-calendar-count-days '(2 28 2012) '(3 1 2012)) 3))
  (should (equal (my-calendar-count-days '(2 28 2014) '(3 1 2014)) 2)))

Now, for step 2, I can't find any built-in function to calculate weekend days for a date range (surprisingly!). Luckily, this /might/ be pretty simple when working with absolute dates. Here's a very naive attempt which simply loops through all absolute dates in the range and looks for Saturdays & Sundays:

(defun my-calendar-count-weekend-days(date1 date2)
  (let* ((tmp-date (if (< date1 date2) date1 date2))
         (end-date (if (> date1 date2) date1 date2))
         (weekend-days 0))
    (while (<= tmp-date end-date)
      (let ((day-of-week (calendar-day-of-week
                          (calendar-gregorian-from-absolute tmp-date))))
        (if (or (= day-of-week 0)
                (= day-of-week 6))
            (incf weekend-days ))
        (incf tmp-date)))
    weekend-days))

That function should be optimized since it does a bunch of unnecessary looping (e.g. we know that the 5 days after Sunday won't be weekend days, so there is no need to convert & test them), but for the purpose of this example I think it's pretty clear and simple. Good Enough for now, indeed. Some tests:

(ert-deftest test-count-weekend-days ()
  "Test my-calendar-count-weekend-days function"
  (should (equal (my-calendar-count-weekend-days
          (calendar-absolute-from-gregorian '(5 1 2014))
          (calendar-absolute-from-gregorian '(5 31 2014))) 9))
  (should (equal (my-calendar-count-weekend-days
          (calendar-absolute-from-gregorian '(4 28 2014))
          (calendar-absolute-from-gregorian '(5 2 2014))) 0))
  (should (equal (my-calendar-count-weekend-days
          (calendar-absolute-from-gregorian '(2 27 2004))
          (calendar-absolute-from-gregorian '(2 29 2004))) 2)))

Lastly, we need to know the holidays in the range, and emacs provides this in the holiday-in-range function! Note that this function calls calendar-holiday-list to determine which holidays to include, so if you really want to search only holiday-general-holidays and holiday-local-holidays you would need to set your calendar-holidays variable appropriately. See C-h v calendar-holidays for the details.

Now we can wrap all this up in a new interactive function which does the three steps above. This is essentially another modified version of calendar-count-days-region that subtracts weekends and holidays before printing the results (see edit below before running):

(defun calendar-count-days-region2 ()
  "Count the number of days (inclusive) between point and the mark 
  excluding weekends and holidays."
  (interactive)
  (let* ((d1 (calendar-cursor-to-date t))
         (d2 (car calendar-mark-ring))
         (date1 (calendar-absolute-from-gregorian d1))
         (date2 (calendar-absolute-from-gregorian d2))
         (start-date (if (<  date1 date2) date1 date2))
         (end-date (if (> date1 date2) date1 date2))
         (days (- (my-calendar-count-days d1 d2)
                  (+ (my-calendar-count-weekend-days start-date end-date)
                     (my-calendar-count-holidays-on-weekdays-in-range
                      start-date end-date)))))
    (message "Region has %d workday%s (inclusive)"
             days (if (> days 1) "s" ""))))

I'm sure someone more knowledgeable about lisp/elisp could simplify/improve these examples considerably, but I hope it at least serves as a starting point.

Actually, now that I've gone through it, I expect somebody to come along any minute and point out that there is an emacs package that already does this...

Edit: DOH!, Bug #001: If a holiday falls on a weekend, that day is removed twice...

Once solution would be to simply wrap holiday-in-range so we can eliminate holidays which were already removed for being on a weekend:

 (defun my-calendar-count-holidays-on-weekdays-in-range (start end)
  (let ((holidays (holiday-in-range start end))
        (counter 0))
    (dolist (element holidays)
      (let ((day (calendar-day-of-week (car element))))
        (if (and (> day 0)
                 (< day 6))
            (incf counter))))
    counter))

I've updated the calendar-count-days-region2 above to use this new function.

Upvotes: 4

Related Questions