0

I have an array of objects, which contains "year/month" as a string value. I compare them as date objects, but the problem is that it fails if I am comparing year and the first month, as it is in the code: "2015" and "2015/1"

const sortYearsMonthsDescending = (items) => items.sort((a, b) => (new Date(a.value) - new Date(b.value)));

const toBeSortedWorking = [{value: "2015"}, {value:"2015/3"}, {value: "2015/10"}];
const toBeSortedNotWorking = [{value: "1933"}, {value:"1933/11"}, {value: "1933/1"}];
console.log(sortYearsMonthsDescending(toBeSortedWorking));
console.log(sortYearsMonthsDescending(toBeSortedNotWorking));
Shota
  • 6,910
  • 9
  • 37
  • 67

5 Answers5

0

let dates = ['2012','2018/1','2018','2014/3','2015','2012/5','2019','2013/7','2011/3','2010/10','2039/3'];

function compareDates(date1, date2) {
  for (let i = 0; i < Math.max(date1.length, date2.length); i++) {
    if (date1[i] > date2[i]) return 1;
    else if (date1[i] < date2[i]) return -1;
    else if (!date1[i] && date2[i]) return -1;
    else if (date1[i] && !date2[i]) return 1;
  }
  return 0;
}

console.log(dates.sort(compareDates));
Robert Taussig
  • 581
  • 2
  • 11
  • This does not correctly sort single and double digit months, e.g. 2010/10 and 2010/2. You need to pad single digit months with a leading zero to get them to sort correctly as strings. – RobG May 19 '17 at 00:20
  • Ah, good catch. Fixing it wouldn't be difficult, but it would make it a bit messier, and I think there are enough solutions posted that work correctly. – Robert Taussig May 19 '17 at 04:48
0

You can easily do it because Date is a comparable object Compare two dates with JavaScript

const a = [{value: "2015"}, {value:"2015/3"}, {value: "2015/10"}];
const b = [{value: "1932"}, {value:"1933/11"}, {value: "1932/1"}];

function sort(items) {
  
  return items
    .sort((a, b) => {
      a = a.value.split('/');
      b = b.value.split('/');
      
      // months at Index = 1, consider as 0 based
      a[1] = a[1] ? (a[1] - 1) : 0;
      b[1] = b[1] ? (b[1] - 1) : 0;
      
      /* ASC */ return (new Date(...a)) - (new Date(...b)); 
      /* DESC */ return (new Date(...b)) - (new Date(...a)); 
    })
  ;
}

console.log('a = ', sort(a));
console.log('b = ', sort(b));
Community
  • 1
  • 1
Hitmands
  • 13,491
  • 4
  • 34
  • 69
  • Re-indexing the months isn't necessary as they will sort correctly without it. The dates will be a month out, but it doesn't matter as they aren't used for anything else. This will treat "1933" and "1933/1" both as `new Date(1933,0)` so sorting may not be stable or correct. – RobG May 19 '17 at 00:57
0

There is no way to initialize the date in a way that distinguishes between having provided a value for the month or not since it uses default values for all in order to be able to create a valid date.

In this particular use-case I think you can add a secondary calculation on the length of the date strings, like this:

const sortYearsMonthsDescending = (items) => items.sort((a, b) => 
  (new Date(a.value) - new Date(b.value))) || (a.value.length - b.value.length);

Basically, this is an edge case where there's an ambiguity between "2015" and "2015/1". We resolve this ambiguity with the statement "take the shorter string before the longer string".

Lennholm
  • 7,205
  • 1
  • 21
  • 30
  • You shouldn't use `new Date(a.value)` since parsing of a string like "2010/11" is entirely implementation dependent. – RobG May 19 '17 at 00:23
0

Note that bellow I included also the day for extensibility purposes. If not needed at all then can be easily removed.

Solution 1 - string sorting key YYYY-MM-DD-LLLLL

The following solution creates a key for sorting without using the Date object and considers also that a string without month or day should be treated first in an ascending sorting order.

The sorting key is: YYYY-MM-DD-LLLLL where L is length.

  const ensureDigits = (value, digits) => {
    return (Math.pow(10, digits) + value).toString().slice(- digits);
  };
  
  const sortDateStrings = (dateStrLst, descending) => {
    
    return dateStrLst
      .map(dateStr => {
        const parts = dateStr.value.split('/');
        const year = parts[0];
        const month = ensureDigits(parts[1] || 1, 2);
        const day = ensureDigits(parts[2] || 1, 2);
        const length = ensureDigits(dateStr.value.length, 5);
        return { item: dateStr, sortKey: `${year}-${month}-${day}-${length}` }
      })
      .sort((model1, model2) => 
        model1.sortKey.localeCompare(model2.sortKey) * (descending ? -1 : 1))
      .map(model => model.item);
  }
  
  const dateStrLst1 = [{value: "2015"}, {value:"2015/3"}, {value: "2015/10"}];
  const dateStrLst2 = [{value: "1932"}, {value:"1933/11"}, {value: "1932/1"}];
  
  console.log(sortDateStrings(dateStrLst1).map(item => item.value));
  console.log(sortDateStrings(dateStrLst2).map(item => item.value));
  
  console.log(sortDateStrings(dateStrLst1, true).map(item => item.value));
  console.log(sortDateStrings(dateStrLst2, true).map(item => item.value));

Solution 2 - order by date, then by length, using Lodash

  const sortDateStrings = (dateStrLst, descending) => {
    
    const model = dateStrLst
      .map(dateStr => {
        const parts = dateStr.value.split('/');
        const year = parts[0];
        const month = (parts[1] - 1) || 0;
        const day = parts[2] || 1;
        return { item: dateStr, date: new Date(year, month, day), length: dateStr.value.length };
      });
      
      const direction = descending ? 'desc' : 'asc';
    
    return _.orderBy(model, ["date", "length"], [direction, direction])
      .map(modelItem => modelItem.item);
  };
  
  const dateStrLst1 = [{value: "2015"}, {value:"2015/3"}, {value: "2015/10"}];
  const dateStrLst2 = [{value: "1932"}, {value:"1933/11"}, {value: "1932/1"}];
  
  console.log(sortDateStrings(dateStrLst1).map(item => item.value));
  console.log(sortDateStrings(dateStrLst2).map(item => item.value));
  
  console.log(sortDateStrings(dateStrLst1, true).map(item => item.value));
  console.log(sortDateStrings(dateStrLst2, true).map(item => item.value));
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title></title>
    <link rel="stylesheet" href="style.css" />
    <script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.16.2/lodash.js"></script>
  </head>

  <body>
  </body>
</html>

WARNING: parsing date

// valid ISO 8601 string
const date1 = new Date(Date.parse('1932'));
Fri Jan 01 1932 02:00:00 GMT+0200 (GTB Standard Time)

// not a valid string
const date2 = new Date(Date.parse('1932/1'));
Fri Jan 01 1932 00:00:00 GMT+0200 (GTB Standard Time)

// not a valid string
const date3 = new Date(Date.parse('1932/01'));
Fri Jan 01 1932 00:00:00 GMT+0200 (GTB Standard Time)

date1.getTime() == date2.getTime();
false

also the same happens with:

// not a valid ISO 8601 string
const date2 = new Date(Date.parse('1932-1'));
Fri Jan 01 1932 00:00:00 GMT+0200 (GTB Standard Time)

but this one is correct:

// valid ISO 8601 string
const date3 = new Date(Date.parse('1932-01'));
Fri Jan 01 1932 02:00:00 GMT+0200 (GTB Standard Time)

and the same problem happens also with:

// incorrect usage of constructor
new Date(1932);
Thu Jan 01 1970 02:00:01 GMT+0200 (GTB Standard Time)

// correct usage of constructor
new Date(1932, 0);
Fri Jan 01 1932 00:00:00 GMT+0200 (GTB Standard Time)

But this last one new Date(1932) is incorrect as the constructor allows at least year + month as in new Date(year, month[, date[, hours[, minutes[, seconds[, milliseconds]]]]]);.

Note that before I used new Date(Date.parse(...)) because:

Note: parsing of date strings with the Date constructor (and Date.parse, they are equivalent) is strongly discouraged due to browser differences and inconsistencies.

Date can parse:

A string representing an RFC2822 or ISO 8601 date (other formats may be used, but results may be unexpected).

In respect to this question, ISO 8601 formats are (notice that days and months are two digits only):

   Year:
      YYYY (eg 1997)
   Year and month:
      YYYY-MM (eg 1997-07)
   Complete date:
      YYYY-MM-DD (eg 1997-07-16)

In respect to this question, RFC2822 formats are:

date-time       =       [ day-of-week "," ] date FWS time [CFWS]
date            =       day month year

Therefore I find no format to match something similar to 1932/1 in order to parse consistently across browsers.

Performance wise might be to stick with ISO 8601 format as is usually the first one being checked, then the RFC2822.

If you are using momentjs ensure that the format you are about to parse is supported by checking parsing strings section.

Conclusion:

Create date objects consistently in order to obtain correct results:

  • Date constructor: (gives local date) - new Date(1932, 0)
  • Date.parse() (gives UTC date when used with ISO 8601 date only) - Date.parse('1932') same as Date.parse('1932-01') - notice the two digits for month

Date.parse(<ISO_8601_string>) gives UTC date (when date only) because:

Support for ISO 8601 formats differs in that date-only strings (e.g. "1970-01-01") are treated as UTC, not local.

Community
  • 1
  • 1
andreim
  • 3,365
  • 23
  • 21
  • I guess what you're saying is that '1932' will be parsed as UTC but '1932/1' as local. But '1932/1' may not be parsed as a valid date string at all. :-( – RobG May 19 '17 at 00:25
  • @RobG I updated my answer to include also aspects regarding parsing. `1932/1` is indeed not a valid format which can work across browsers. – andreim May 19 '17 at 06:32
0

You just need to get the strings in a format that is easily comparable, either as strings or dates. However, using Dates gives an added issue of how to ensure 1933 is correctly sorted between 1932/12 and 1933/1.

The following sorts as strings by appending "/00" to any value that is only a year, and adding a leading zero to single digit months so "2015/3" is compared as "2015/03".

var values  = [{value: "2015"}, {value:"2015/3"}, {value: "2015/10"}, {value: "1933"}, {value: "1932/12"}, {value:"1933/11"}, {value: "1933/1"}];

console.log(JSON.stringify(values));

values.sort(function(a, b) {
  function fixMonth(date) {
    var t = date.split('/');
    return t[0] + '/' + ('0'+t[1]).slice(-2); 
  }
  a = /\//.test(a.value)? fixMonth(a.value) : a.value + '/00';
  b = /\//.test(b.value)? fixMonth(b.value) : b.value + '/00';
  return a.localeCompare(b);
})

console.log(JSON.stringify(values));

If a descending sort is required, just reverse the result.

Another version is:

var values  = [{value: "2015"}, {value:"2015/3"}, {value: "2015/10"}, {value: "1933"}, {value: "1932/12"}, {value:"1933/11"}, {value: "1933/1"}];

values.sort(function(a, b) {
  a = /\//.test(a.value) ? a.value.replace(/(\/)(\d)$/, "$10$2") : a.value + '/00';
  b = /\//.test(b.value) ? b.value.replace(/(\/)(\d)$/, "$10$2") : b.value + '/00';
  return a.localeCompare(b);
})

console.log(JSON.stringify(values));

Or even:

var values  = [{value: "2015"}, {value:"2015/3"}, {value: "2015/10"}, {value: "1933"}, {value: "1932/12"}, {value:"1933/11"}, {value: "1933/1"}];

values.sort(function(a, b) {
  function resolve(s) {
    return /\//.test(s) ? s.replace(/(\/)(\d)$/, "$10$2") : s + '/00';
  }
  return resolve(a.value).localeCompare(resolve(b.value));
})

console.log(JSON.stringify(values));
RobG
  • 142,382
  • 31
  • 172
  • 209