3

This has been asked (badly) before - I don't think the answer in that post really addressed the issue, and then it went stale. I'm going to attempt to ask it again with a clearer demonstration of the issue.

The implementation of Javascript Date.setMonth() appears not to follow the principle of least surprise. Try this in a browser console:

d = new Date('2017-08-31')  // Set to last day of August
d.getMonth()  // 7 - months are zero-based
d.setMonth(8)  // Try to set the month to 8 (September)
d.getMonth()  // 9 - October. WTF Javascript?

Similarly:

d = new Date('2017-10-31')
d.getMonth()  // 9
d.setMonth(8)
d.getMonth() // 9 (still?)

Firefox on Linux appears even worse - sometimes returning a date in October, and a result from getMonth() which doesn't match that month!

My question (and I think that of the OP from that linked question) is how to consistently implement a 'next' / 'prev' month function in, e.g. a datepicker? Is there a well known way of doing this which doesn't surprise the user by, for example, skipping September when they start on August 31st and click 'next'? Going from January 31st is even more unpredictable currently - you will end up on either March 2nd or March 3rd, depending on whether it's a leap year or not!

My personal view is that the least surprise would be to move to the last day of the next / previous month. But that requires the setMonth() implementation to care about the number of days in the months in question, not just add / subtract a fixed duration. According to this thread, the moment.js approach is to add / subtract the number of milliseconds in 30 days, which suggests that library would be prone to the same inconsistencies.

dsl101
  • 1,715
  • 16
  • 36

5 Answers5

2

It's all simple and logic. Lets take your example and go see what id does.

So the first line

d = new Date('2017-08-31')  // Set to last day of August
console.log(d);             // "2017-08-31T00:00:00.000Z"
console.log(d.getMonth());  // 7 - months are zero-based

So all good so far. Next step: Your comment says it: // Try to set the month to 8 (September) So it's not done with trying. You either set it to september or you don't. In your example you set it to October. Explanation further down.

d = new Date('2017-08-31')  // Set to last day of August
console.log(d);             // "2017-08-31T00:00:00.000Z"
console.log(d.getMonth());  // 7 - months are zero-based
d.setMonth(8)               // Try to set the month to 8 (September)
console.log(d);             // but now I see I was wrong it is (October)

So the good question is WHY? From MDN

Note: Where Date is called as a constructor with more than one argument, if values are greater than their logical range (e.g. 13 is provided as the month value or 70 for the minute value), the adjacent value will be adjusted. E.g. new Date(2013, 13, 1) is equivalent to new Date(2014, 1, 1), both create a date for 2014-02-01 (note that the month is 0-based). Similarly for other values: new Date(2013, 2, 1, 0, 70) is equivalent to new Date(2013, 2, 1, 1, 10) which both create a date for 2013-03-01T01:10:00.

So that sayd September has only 30 Days but the Date Object has 31. This is why it gives you October and not September.

The simplest will be to take the date you have and set it to first day of month. Something like so:

var d = new Date('2017-08-31')  // Set to last day of August
// simplest fix take the date you have and set it to first day of month
d = new Date(d.getFullYear(), d.getMonth(), 1); 
console.log(d);             // "2017-08-31T00:00:00.000Z"
console.log(d.getMonth());  // 7 - months are zero-based
d.setMonth(8)               // Set the month to 8 (September)
console.log(d.getMonth());  // get 8 it is (September)
caramba
  • 21,963
  • 19
  • 86
  • 127
  • Your solution boils down to setting the date to 1 to avoid the edge case. Again, I don't think a month back / forward button should change a date from the end of the month to the beginning - more user surprise... I can understand the explanation, but I still think `setMonth()` should really do more. to cater for this. – dsl101 Sep 01 '17 at 22:18
  • @dsl101 ok, voting to close this question. You rather contact javascript developers and tell them that you are not happy with their implementation of `setMonth()` but honestly I don't think they want to listen to that. Cause it's not `setMonth()` who should take care of that, it is YOU. Cause you are the guy who knows what should happen on edge cases, you decide. The javascript Docs tell you exactly what happens, when it happens, how it happens, why it happens. You can handle it all. It's like liberty. There is nothing wrong with `setMonth()` but see that's my opinion... – caramba Sep 02 '17 at 07:07
0

Since getMonth() returns an integer number, you can simply implement a generator over the date object, that sets the month + 1 or - 1 so long as your not at month 11 or month 0 respectively.

function nextMonth(dateObj) {
  var month = dateObj.getMonth();
  if(month != 11) dateObj.setMonth(month + 1);
  return dateObj;
}

function prevMonth(dateObj) {
  var month = dateObj.getMonth();
  if(month != 0) dateObj.setMonth(month - 1);
  return dateObj;
}

If you want to match the days in the previous month you can use an object lookup table.

Now, for your last day of the month problem:

function getLastDayofMonth(month) {
  var lookUp = {
    0:31,
    1:28,
    2:30,
    3:31
  };
  return lookUp[month];
}

//and then a revised version

function nextMonth(dateObj) {
  var month = dateObj.getMonth();
  var day = dateObj.getDate();
  if(month != 12) dateObj.setMonth(month + 1);
  if(getLastDayofMonth(month)<day)dateObj.setDate(getLastDayofMonth(month));
  return dateObj;
}
Josh Weinstein
  • 2,788
  • 2
  • 21
  • 38
0

This should work for incrementing the month, you can use a similar strategy to decrement.

// isLeapYear :: Number -> Boolean
const isLeapYear = ((err) => {
  return yr => {
    // check for the special years, see https://www.wwu.edu/skywise/leapyear.html
    if (yr === 0) {
      throw err;
    }
    // after 8 AD, follows 'normal' leap year rules
    let passed = true;
    // not technically true as there were 13 LY BCE, but hey.
    if (yr === 4 || yr < 0 || (yr % 4)) {
      passed = false;
    } else {
      if (yr % 400) {
        if (!(yr % 100)) {
          passed = false;
        }
      }
    }
    return passed;
  };
})(new Error('Year zero does not exist, refers to 1 BCE'));

const daysInMonth = [
  31,
  28,
  31,
  30,
  31,
  30,
  31,
  31,
  30,
  31,
  30,
  31
];

// isLastDay :: Number, Number -> Boolean
const isLastDay = (d, m, y) => {
  let dm = isLeapYear(y) && m === 1 ? 29 : daysInMonth(m);
  return dm === d;
};

// getLastDay :: Number, Number -> Number
const getLastDay = (m, y) => isLeapYear(y) && m === 1 ? 29 : daysInMonth[m];

// incMonth :: Date -> Date
const incMonth = d => {
  let dd = new Date(d.getTime());
  let day = dd.getDate();
  let month = dd.getMonth() + 1;
  dd.setDate(5);  // should avoid edge-case shenanigans
  dd.setMonth(month);
  let year = dd.getFullYear();
  if (isLastDay(day, month, year)) day = getLastDay(month, year);
  dd.setDate(day);
  return dd;
};
Jared Smith
  • 19,721
  • 5
  • 45
  • 83
  • As with @caramba's answer, it boils down to changing the day to avoid the edge case - which I really don't want to do. If you'd selected August 27th and then clicked 'next' (month), you wouldn't expect that to show September 5th... – dsl101 Sep 01 '17 at 22:20
0

This was the solution I came up with, which seems small and reliable as far as I can tell. It doesn't need any extra data structures, and relies on setDate(0) to select the last day of the month in the edge cases. Otherwise it leaves the date alone, which is the behaviour I wanted. It also handles wrapping round from one year to the next (in either direction):

function reallySetMonth(dateObj, targetMonth) {
  const newDate = new Date(dateObj.setMonth(targetMonth))
  if (newDate.getMonth() !== ((targetMonth % 12) + 12) % 12) {  // Get the target month modulo 12 (see https://stackoverflow.com/a/4467559/1454454 for details about modulo in Javascript)
    newDate.setDate(0)
  }
  return newDate
}

Note I've only tested this with targetMonth being either one higher or lower than the current month, since I'm using it with 'next' / 'back' buttons. It would need testing further user with arbitrary months.

dsl101
  • 1,715
  • 16
  • 36
  • `targetMonth % 12` is redundant. – RobG Sep 02 '17 at 08:25
  • It's probably belt and braces in this instance, but it's from here: https://stackoverflow.com/a/4467559/1454454 If targetMonth was less than -12, it wouldn't work without it I don't think. – dsl101 Sep 02 '17 at 12:15
  • Hmm, interesting issue. Perhaps you should include a reference in the answer. – RobG Sep 04 '17 at 00:29
  • BTW - I think our solutions are equivalent, but yours is shorter and doesn't require that obscure modulo expression, so I've marked it as correct. Tx. – dsl101 Sep 04 '17 at 08:05
0

If setMonth is used when adding and subtracting months, then if the date of the start month doesn't exist in the end month, the extra days cause the date to "roll over" to the next month, so 31 March minus 1 month gives 2 or 3 March.

A simple algorithm is to test the start date and end date and if they differ, set the end date to 0 so it goes to the last day of the previous month.

One issue with this is that subtracting 1 month twice may not give the same result as subtracting 2 months once. 31 March 2017 minus one month gives 28 Feb, minus another month gives 28 Jan. Subtract 2 months from 31 March and you get 31 Jan.

C'est la vie.

function addMonths(date, num) {
  var d = date.getDate();
  date.setMonth(date.getMonth() + num);
  if (date.getDate() != d) date.setDate(0);
  return date;
}

// Subtract one month from 31 March
var a = new Date(2017,2,31);
console.log(addMonths(a, -1).toString()); // 28 Feb

// Add one month to 31 January
var b = new Date(2017,0,31);
console.log(addMonths(b, 1).toString()); // 28 Feb

// 29 Feb plus 12 months
var c = new Date(2016,1,29)
console.log(addMonths(c, 12).toString()); // 28 Feb

// 29 Feb minus 12 months
var c = new Date(2016,1,29)
console.log(addMonths(c, -12).toString()); // 28 Feb

// 31 Jul minus 1 month
var d = new Date(2016,6,31)
console.log(addMonths(d, -1).toString()); // 30 Jun
RobG
  • 142,382
  • 31
  • 172
  • 209
  • Yeah, I think that's pretty similar to the solution I came up with. I checked for the month not being equal to the month you were aiming for, but I guess checking for the day having changed is pretty much equivalent. – dsl101 Sep 02 '17 at 12:17