'Sum and combine similar object elements in array if condition is met - Javascript

I have an array of this shape:

[
  {
    unique: 8,
    views: 24,
    name: "https://l.instagram.com/",
    sec: 5.39,
  },
  {
    unique: 2,
    views: 20,
    name: "",
    sec: 0,
  },
  {
    unique: 2,
    views: 5,
    name: "https://www.tiktok.com/",
    sec: 5.39,
  },
  {
    unique: 4,
    views: 3,
    name: "https://l.instagram.com",
    sec: 2.00,
  },
  {
    unique: 1,
    views: 2,
    name: "https://www.tiktok.com",
    sec: 2.00,
  },
];

And I'm trying to combine the same referrers with sum of the values (views, unique etc) into a new array. The new array needs to be formed based on condition, basically referrer names are need to be formatted based on the type. E.g. all ("https://l.instagram.com/", "l.instagram.com", "https://www.instagram.com/" etc) referrers need to be called Instagram and same with others - YouTube, TikTok etc. and 'Direct' if it's null. This is what I have in mind as final results:

[
  {
    unique: 12,
    views: 27,
    name: "Instagram",
    sec: 7.39,
  },
  {
    unique: 2,
    views: 20,
    name: "Direct",
    sec: 0,
  },
  {
    unique: 3,
    views: 7,
    name: "TikTok",
    sec: 7.39,
  },
];

My thought is to map through the array and if the value includes the desired string it will sum the values in a new object. Maybe map or reduce, or a combination of both? Any guide or help is appreciated. Thanks in advance!



Solution 1:[1]

(sorry, I missed this 'direct' requirement, will modify and append)

Array.map, and all array looping methods, iterate through the array an element at a time, without retaining information of previous elements, and blind to future elements so some means of keeping track of previous objects is needed while looping through your object.

In my proposed snippet solution, I declare an intermediate object containing objects for each of the different groups you want to collect together (tiktok and instagram in your case, - you may beed to extract these programatically if your initial data set is very complex but in this example I simply read them by eye).

Next, the original array of objects is looped through using a for-each loop. for each element (object), the .name property is examined and an attempt to extract a substring that can be used as a key to reference the matching object in the intermediate array. This was done by splitting the .name into an array, breaking the name string wherever a period (.) was found. Because your names are all of the type (something).tiktok.(something), the unique key (tiktok) can be extracted as the seceond element (index 1) of that split array:

// suppose current element.name is "https://www.tiktok.com";
key = element.name.split(".")[1];
// key now holds 'tiktok';

This key can now be used to reference the relevant object in the intermediate object of objects we declared earlier, and individual properties can be accessed and updated like this:

 intermediateObjectOfObjects[key].unique += element.unique;

The statements of the for-each loop are put inside a conditional if block to ignore any where no name is defined.

When the for-each loop is finished, the data will now be in an object of objects (with the inner objects all updated as you want them).

So, finally we just need to move the updated objects into an array of their own:

// define a new array:
const finalObjectArray=[];
// iterate through the intermediate array, adding each inner object to the new array:

     for (const property in intermediateObjectOfObjects) {
       finalObjectArray.push(intermediateObjectOfObjects[property]);
     } // end for-in object iterations;

The final result is an array containing one object for each named group, with the values from the original array of objects combined for each.

const arrayOfObjects = [
  {
    unique: 8,
    views: 24,
    name: "https://l.instagram.com/",
    sec: 5.39,
  },
  {
    unique: 2,
    views: 20,
    name: "",
    sec: 0,
  },
  {
    unique: 2,
    views: 5,
    name: "https://www.tiktok.com/",
    sec: 5.39,
  },
  {
    unique: 4,
    views: 3,
    name: "https://l.instagram.com",
    sec: 2.00,
  },
  {
    unique: 1,
    views: 2,
    name: "https://www.tiktok.com",
    sec: 2.00,
  },
];

const intermediateObjectOfObjects = {

   tiktok: {
           unique: 0,
           views: 0,
           name: "https://www.tiktok.com",
           sec: 0
           },

instagram: {
           unique: 0,
           views: 0,
           name: "https://www.instagram.com",
           sec: 0
           },

direct: {
           unique: 0,
           views: 0,
           name: "direct",
           sec: 0
           }

} // end intermediate object;

const finalObjectArray=[];


  arrayOfObjects.forEach(element => {

     key = (element.name.split(".")[1]) ? element.name.split(".")[1] : 'direct' ;
     intermediateObjectOfObjects[key].unique += element.unique;
     intermediateObjectOfObjects[key].views += element.views;
     intermediateObjectOfObjects[key].sec += element.sec;

});


     for (const property in intermediateObjectOfObjects) {
       finalObjectArray.push(intermediateObjectOfObjects[property]);
     } // end for-in object iterations;

console.log(finalObjectArray);

Edits to include 'direct' group for undefined object names:

Instead of the if conditional to test for a name, I instead used a ternary operator to ask "did split('.') return a valid string in element[1]? is so use it as name, if not use 'direct' as name"

(the syntax looks odd but means what I quotted above:

 key = (element.name.split(".")[1]) ? element.name.split(".")[1] : 'direct' ;

Of course, the intermediate object of objects also had to be amended to include 'direct' as a named object;

Solution 2:[2]

You can use a combination of:

const data = [{unique: 8,views: 24,name: "https://l.instagram.com/",sec: 5.39}, {unique: 2,views: 20,name: "",sec: 0}, {unique: 2,views: 5,name: "https://www.tiktok.com/",sec: 5.39}, {unique: 4,views: 3,name: "https://l.instagram.com",sec: 2.00}, {unique: 1,views: 2,name: "https://www.tiktok.com",sec: 2.00}];

const result = Object.entries( 
    data
    .map(({name,...rest}) => ({...rest,name:name.split('.').slice(-2)[0] || "direct" }))
    .reduce((prev,{name,...rest}) =>
        ({...prev,[name]:Object.fromEntries(
            Object.entries(rest).map(([k,v]) => [k, (prev[name] && prev[name][k] || 0) + v])
        )}), {})
)
.map(([name,rest]) => ({name,...rest}));

console.log( result );

Solution 3:[3]

Get the names in a separate array. Iterate that array and match it with the main array. You can also try getting the name of the website by splitting with (.) and in the is statement change === to includes. Something like this:

    let items = [
        {
          unique: 8,
          views: 24,
          name: "https://l.instagram.com",
          sec: 5.39,
        },
        {
          unique: 2,
          views: 20,
          name: "",
          sec: 0,
        },
        {
          unique: 2,
          views: 5,
          name: "https://www.tiktok.com",
          sec: 5.39,
        },
        {
          unique: 4,
          views: 3,
          name: "https://l.instagram.com",
          sec: 2.00,
        },
        {
          unique: 1,
          views: 2,
          name: "https://www.tiktok.com",
          sec: 2.00,
        },
    ];
  
  var resultArr = [];
  var valueArr = [...new Set(items.map(function(item){ return item.name }))];
  console.log(valueArr);
  valueArr.map((name) => {
      let crrItem = {
          name: name,
          unique: 0,
          views: 0,
          sec: 0
      }
      items.map((item) => {
        //   console.log(item.name === name);
          if(item.name === name){
              crrItem.unique += item.unique;
              crrItem.views += item.views;
              crrItem.sec += item.sec;
            //   console.log(crrItem);
          }
      });
      if(crrItem.unique > 0){
          resultArr.push(crrItem);
      }
  });
  console.log(resultArr);

Solution 4:[4]

First off, please in the future, show your own attempts. I wouldn't be posting a solution if others hadn't already, as there was not enough effort demonstrated in the question itself.


The biggest challenge in this is simply converting, say, "https://www.tiktok.com" to "TikTok". My initial solution uses a regex test: /tiktok/i .test (name). Separate ones for Instagram and TikTok are here, and it's obvious how to add additional ones. Everything else is fairly straightforward. It looks like this:

const groupBy = (fn) => (xs) =>
  xs .reduce ((a, x, _, __, k = fn (x)) => ((a [k] = (a [k] || []) .concat (x)), a), {})

const sumOn = (fields) => (xs) =>
  Object .fromEntries (fields .map ((field) => [field, xs .reduce ((a, x) => a + x [field], 0)]))

const groupName = ((rules = [
  ['Instagram', ({name}) => /instagram/i .test (name)],
  ['TikTok', ({name}) => /tiktok/i .test (name)],
  ['Direct', () => true] // must come last
]) => (name) => rules .find (([_, test]) => test (name)) [0])()

const convert = (data) => Object .entries (
  groupBy (groupName) (data)
) .map (([name, xs]) => ({name, ...sumOn (['unique', 'views', 'sec']) (xs)}))


const data = [{unique: 8, views: 24, name: "https: //l.instagram.com/", sec: 5.39}, {unique: 2, views: 20, name: "", sec: 0}, {unique: 2, views: 5, name: "https: //www.tiktok.com/", sec: 5.39}, {unique: 4, views: 3, name: "https: //l.instagram.com", sec: 2}, {unique: 1, views: 2, name: "https: //www.tiktok.com", sec: 2}]

console .log (convert (data))
.as-console-wrapper {max-height: 100% !important; top: 0}
  • groupBy (now a stage-3 proposal for an extension to Array.prototype, so may not be needed for much longer) groups the elements of an array according to a the result of a key-generating function. That is,

    groupBy ((n) => n % 2 == 0 ? 'even' : 'odd') ([1, 2, 3, 4, 5])
    //=> {odd: [1, 3, 5], even: [2, 4]} 
    
  • sumOn takes a list of property names and returns a function that takes a list of objects and returns a new object with each of those property names, calculated as the sum of that property for each of the supplied objects.

    sumOn (['a', 'c']) ([{a: 1, b: 2, c: 3}, {a: 10, b: 20, c: 30}, {a: 100, b: 200, c: 300})
    //=> {a: 111, c: 333}
    
  • groupName is what we discussed above. It hard-codes regular expressions for Instagram and TikTok, with tests to see if those strings are included in the name, defaulting to "Direct" if those don't match.

  • convert is the main function, which does little more than combine those function, using groupName as the key-generating function for groupBy, taking the entries, then combining the group names with the result of calling sumOn for the values, with keys of ['unique', 'views', 'sec'].

This may be fine as is, but I was thinking about how to make this more configurable, and while I won't go through the details, I do have a solution that lets us configure "Instagram" and "TikTok" directly, as well as list the fields that we want to total. It looks like this:

const groupBy = (fn) => (xs) =>
  xs .reduce ((a, x, _, __, k = fn (x)) => ((a [k] = (a [k] || []) .concat (x)), a), {})

const sumOn = (fields) => (xs) =>
  Object .fromEntries (fields .map ((field) => [field, xs .reduce ((a, x) => a + x [field], 0)]))

const groupName = ((groups, defaultGroup, rules = [
  ...groups .map (group => [group, ({name}) => new RegExp (group.toLowerCase (), 'i') .test (name)]),
  [defaultGroup, () => true]
]) => (name) => rules .find (([_, test]) => test (name)) [0])

const makeConverter = (
  {groups, defaultGroup, totals}, 
  grouper = groupName (groups, defaultGroup),
  totaler = sumOn (totals)
) => (data) => 
  Object .entries (groupBy (grouper) (data)) .map (([name, xs]) => ({name, ... totaler (xs)}))

const convert = makeConverter ({
  groups: ['Instagram', 'TikTok'], 
  defaultGroup: 'Direct', 
  totals: ['unique', 'views', 'sec']
}) 

const data = [{unique: 8, views: 24, name: "https: //l.instagram.com/", sec: 5.39}, {unique: 2, views: 20, name: "", sec: 0}, {unique: 2, views: 5, name: "https: //www.tiktok.com/", sec: 5.39}, {unique: 4, views: 3, name: "https: //l.instagram.com", sec: 2}, {unique: 1, views: 2, name: "https: //www.tiktok.com", sec: 2}]

console .log (convert (data))
.as-console-wrapper {max-height: 100% !important; top: 0}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 PeterKA
Solution 3 ack
Solution 4 Scott Sauyet