How to aggregate nested values in array of objects - javascript

I have an existing function that creates a summary object from two arrays (one array is transaction data, the other is master data).
Currently, the returned object contains a sum of the transaction quantity by year and month - example below.
I'm looking for help to add a sum of sums at the root level for each item - example also below.
I've reviewed answers for similar questions but wasn't able to find a solution close enough to my use case.
Here's the existing code:
const
array1 = [{ material: "ABC123", cost: 100 },
{ material: "DEF456", cost: 150 }],
array2 = [{ material: "ABC123", date: "1/1/20", quantity: 4 },
{ material: "ABC123", date: "1/15/20", quantity: 1 },
{ material: "ABC123", date: "2/15/20", quantity: 3 },
{ material: "ABC123", date: "4/15/21", quantity: 1 },
{ material: "DEF456", date: "3/05/20", quantity: 6 },
{ material: "DEF456", date: "3/18/20", quantity: 1 },
{ material: "DEF456", date: "5/15/21", quantity: 2 }],
groups = [
({ material }) => material,
_ => 'byYear',
({ date }) => '20' + date.split('/')[2],
_ => 'byMonth'
],
sum = {
key: ({ date }) => date.split('/')[0],
value: ({ quantity }) => quantity
},
result = array2.reduce(
(r, o) => {
const temp = groups.reduce((t, fn) => t[fn(o)] ??= {}, r);
temp[sum.key(o)] ??= 0;
temp[sum.key(o)] += sum.value(o);
return r;
},
Object.fromEntries(array1.map(({ material, cost }) => [material, { cost }]))
);
console.log(result);
As mentioned above, I would like it to add a "totalSum" key/value at the root level. The result would look like:
{
"ABC123": {
"totalSum": 8,
"cost": 100,
"byYear": {
"2020": {
"byMonth": {
"1": {
"sum": 5,
"max": 4
},
"2": {
"sum": 3,
"max": 3
}
}
}
}
}
}
Any help is greatly appreciated!

You can actually achieve the entire refactoring with a single Array#reduce() call, combined with a Map of the master array to retrieve cost.
Adding a total sum is then trivial since you have the all the properties to hand and don't have to remap your aggregated data.
const
array1 = [{ material: "ABC123", cost: 100, critical: true }, { material: "DEF456", cost: 150, critical: false }],
array2 = [{ material: "ABC123", date: "1/1/20", quantity: 4 }, { material: "ABC123", date: "1/15/20", quantity: 1 }, { material: "ABC123", date: "2/15/20", quantity: 3 }, { material: "ABC123", date: "4/15/21", quantity: 1 }, { material: "DEF456", date: "3/05/20", quantity: 6 }, { material: "DEF456", date: "3/18/20", quantity: 1 }, { material: "DEF456", date: "5/15/21", quantity: 2 }],
master = new Map(array1.map(({ material, ...item }) => [material, item])),
result = array2.reduce((acc, { material, date, quantity }) => {
let [month, , year] = date.split('/');
year = '20' + year;
const
_material = (acc[material] ??= { ...master.get(material), totalSum: 0, byYear: {} }),
_byYear = (_material.byYear[year] ??= { byMonth: {} }),
_byMonth = (_byYear.byMonth[month] ??= { sum: 0, max: 0 });
_material.totalSum += quantity;
_byMonth.sum += quantity;
_byMonth.max = Math.max(_byMonth.max, quantity);
return acc;
}, {});
console.log(result);
.as-console-wrapper { max-height: 100% !important; top: 0; }
Edit
In a comment you mentioned that you might have more properties in array1 that should be included in the final result. I've edited to accommodate this by changing the Map to reference the entire object as found in array1. We can then include all the properties available using spread syntax when we first declare the relevant object in the accumulator.
// split out 'material' from the rest of the object in the Map
const master = new Map(array1.map(({ material, ...item }) => [material, item]));
// later access the object by material and spread its props into the accumulator object.
_material = (acc[material] ??= { ...master.get(material), totalSum: 0, byYear: {} }),

Related

Create a generic function that creates stacked dataset using d3

I have this dataset:
const dataset = [
{ date: "2022-01-01", category: "red", value: 10 },
{ date: "2022-01-01", category: "blue", value: 20 },
{ date: "2022-01-01", category: "gold", value: 30 },
{ date: "2022-01-01", category: "green", value: 40 },
{ date: "2022-01-02", category: "red", value: 5 },
{ date: "2022-01-02", category: "blue", value: 15 },
{ date: "2022-01-02", category: "gold", value: 25 },
{ date: "2022-01-02", category: "green", value: 35 }
];
And I need to create a stacked barchart. To do that I used the d3 stack() function.
The result I need is this:
const stackedDataset = [
{ date: "2022-01-01", category: "red", value: 10, start: 0, end: 10 },
{ date: "2022-01-02", category: "red", value: 5, start: 0, end: 5 },
{ date: "2022-01-01", category: "blue", value: 20, start: 10, end: 30 },
{ date: "2022-01-02", category: "blue", value: 15, start: 5, end: 20 },
{ date: "2022-01-01", category: "gold", value: 30, start: 30, end: 60 },
{ date: "2022-01-02", category: "gold", value: 25, start: 20, end: 45 },
{ date: "2022-01-01", category: "green", value: 40, start: 60, end: 100 },
{ date: "2022-01-02", category: "green", value: 35, start: 45, end: 80 }
]
So the same data but with a start and end property computed by d3.
I created a function that takes in input dataset and returns stackedDataset:
export function getStackedSeries(dataset: Datum[]) {
const categories = uniq(dataset.map((d) => d[CATEGORY])) as string[];
const datasetGroupedByDateFlat = flatDataset(dataset);
const stackGenerator = d3.stack().keys(categories);
const seriesRaw = stackGenerator(
datasetGroupedByDateFlat as Array<Dictionary<number>>
);
const series = seriesRaw.flatMap((serie, si) => {
const category = categories[si];
const result = serie.map((s, sj) => {
return {
[DATE]: datasetGroupedByDateFlat[sj][DATE] as string,
[CATEGORY]: category,
[VALUE]: datasetGroupedByDateFlat[sj][category] as number,
start: s[0] || 0,
end: s[1] || 0
};
});
return result;
});
return series;
}
export function flatDataset(
dataset: Datum[]
): Array<Dictionary<string | number>> {
if (dataset.length === 0 || !DATE) {
return (dataset as unknown) as Array<Dictionary<string | number>>;
}
const columnToBeFlatValues = uniqBy(dataset, CATEGORY).map(
(d) => d[CATEGORY]
);
const datasetGroupedByDate = groupBy(dataset, DATE);
const datasetGroupedByMainCategoryFlat = Object.entries(
datasetGroupedByDate
).map(([date, datasetForDate]) => {
const categoriesObject = columnToBeFlatValues.reduce((acc, value) => {
const datum = datasetForDate.find(
(d) => d[DATE] === date && d[CATEGORY] === value
);
acc[value] = datum?.[VALUE];
return acc;
}, {} as Dictionary<string | number | undefined>);
return {
[DATE]: date,
...categoriesObject
};
});
return datasetGroupedByMainCategoryFlat as Array<Dictionary<string | number>>;
}
As you can see, the functions are specific for Datum type. Is there a way to modify them to make them works for a generic type T that has at least the three fields date, category, value?
I mean, I would like to have something like this:
interface StackedStartEnd {
start: number
end: number
}
function getStackedSeries<T>(dataset: T[]): T extends StackedStartEnd
Obviously this piece of code should be refactored to make it more generic:
{
[DATE]: ...,
[CATEGORY]: ...,
[VALUE]: ...,
start: ...,
end: ...,
}
Here the working code.
I'm not a TypeScript expert so I need some help. Honestly what I tried to do was to modify the function signature but I failed and, anyway, I would like to make the functions as generic as possible and I don't know how to start.
Do I need to pass to the functions also the used columns names?
Thank you very much
I tried to make a more generic approach as you suggest mixing the two functions. By default, seems like your getStackedSeries function does not need to know about date and category properties, you can use a Generic Type to ensure just the value property, as we need to know that to calculate start and end values.
The full implementation can be viewed here on codesandbox.
export function getStackedSeries<T extends Datum>(
data: T[],
groupByProperty: PropertyType<T>
) {
const groupedData = groupBy(data, (d) => d[groupByProperty]);
const acumulatedData = Object.entries(groupedData).flatMap(
([_, groupedValue]) => {
let acumulator = 0;
return groupedValue.map(({ value, ...rest }) => {
const obj = {
...rest,
value: value,
start: acumulator,
end: acumulator + value
};
acumulator += value;
return obj;
});
}
);
return acumulatedData;
}
The getStackedSeries() now receives a data property that extends Datum type, which is:
export interface Datum {
value: number;
}
With that and a second property called groupByProperty we can define the groupBy clause and return all flatten by flatMap.
You probably notice that the return type now is defined by typescript dynamically by the use of a generic <T>. For example:
const dataGroupedByDate: (Omit<{
date: string;
category: string;
value: number;
}, "value"> & {
value: number;
start: number;
end: number;
})[]
You can also type this part of the function, but makes sense to let the compiler works for you and generate the types automatically based on input.
You could group by date for start/end and take another grouping by category for the result set.
const
dataset = [{ date: "2022-01-01", category: "red", value: 10 }, { date: "2022-01-01", category: "blue", value: 20 }, { date: "2022-01-01", category: "gold", value: 30 }, { date: "2022-01-01", category: "green", value: 40 }, { date: "2022-01-02", category: "red", value: 5 }, { date: "2022-01-02", category: "blue", value: 15 }, { date: "2022-01-02", category: "gold", value: 25 }, { date: "2022-01-02", category: "green", value: 35 }],
result = Object
.values(dataset
.reduce((r, { date, category, value }) => {
const
start = r.date[date]?.at(-1).end ?? 0,
end = start + value,
object = { date, category, value, start, end };
(r.date[date] ??= []).push(object);
(r.category[category] ??= []).push(object);
return r;
}, { date: {}, category: {} })
.category
)
.flat();
console.log(result);
.as-console-wrapper { max-height: 100% !important; top: 0; }

How to filter one array with another array values and return a key value of the first array

I have 2 arrays with current week dates and investments with value and date. I want to return an array with the values that have corresponding dates between the 2 arrays.
My non-working solution is:
const daysOfWeek = [
"20-06-2022",
"21-06-2022",
"22-06-2022",
"23-06-2022",
"24-06-2022",
"25-06-2022",
"26-06-2022",
]
const investmentsData = [{
value: 0.77,
date: "21-06-2022"
},
{
value: 1.50,
date: "22-06-2022"
},
{
value: 0.80,
date: "20-06-2022"
},
{
value: 1.00,
date: "21-06-2022"
},
{
value: 0.77,
date: "20-06-2022"
},
{
value: 0.79,
date: "22-06-2022"
},
{
value: 0.73,
date: "18-06-2022"
},
{
value: 1.29,
date: "19-06-2022"
}
]
const result = investmentsData.flatMap((dayValue) => {
const getDayValue = daysOfWeek.filter((day) => {
return dayValue.date === day;
});
return getDayValue;
});
const filteredResult = result.filter((val) => !!val);
console.log(filteredResult)
// ["21-06-2022", "22-06-2022", "20-06-2022", "21-06-2022", "20-06-2022", "22-06-2022"]
When what I need is:
[0.77, 1.50, 0.80, 1.00, 0.77, 0.79]
Probably the filter inside the map is not the best option as it´s going to return the value of the first array (which is a date).
I also have the problem that result returns also the undefined. I then run filteredResult to remove all the undefined in the result. I guess this is a job that can be done with one function all together.
Take it step by step:
Filter investmentsData on whether or not daysOfWeek contains the date
From the filtered values, return the value.
const daysOfWeek = ["20-06-2022", "21-06-2022", "22-06-2022", "23-06-2022", "24-06-2022", "25-06-2022", "26-06-2022"];
const investmentsData = [
{ value: 0.77, date: "21-06-2022" },
{ value: 1.50, date: "22-06-2022" },
{ value: 0.80, date: "20-06-2022" },
{ value: 1.00, date: "21-06-2022" },
{ value: 0.77, date: "20-06-2022" },
{ value: 0.79, date: "22-06-2022" },
{ value: 0.73, date: "18-06-2022" },
{ value: 1.29, date: "19-06-2022" }
]
const result = investmentsData
.filter(d => daysOfWeek.includes(d.date))
.map(d => d.value);
console.log(result);

Creating a shopping list from an array of ingredients (formatted as objects)

var ingredients = [
{ name: 'potatoes', quantity: 4 },
{ name: 'butter', quantity: 1 },
{ name: 'milk', quantity: 1, description: '1 cup' },
{ name: 'potatoes', quantity: 3 },
{ name: 'oil', quantity: 1, description: '3 cups' } ];
const shoppingList = [];
for (let i = 0; i < ingredients.length; i ++) {
for (let j = 0; j < shoppingList.length; j ++){
let ingredient = ingredients[i];
let shoppingListItem = shoppingList[j];
if(ingredient === shoppingListItem){
break;
}else if (roughDraftItem.name === shoppingListItem.name){
shoppingListItem.quantity += roughDraftItem.quantity;
} else {shoppingList.push(roughDraftItem);
}
}
}
When I run this code the shoppingList array comes back empty. When I take out the second loop the code doesn't have a problem and I get what I need
shoppingListItem = { name: 'potatoes', quantity: 1}
It seems to be a problem of trying to compare the Ingredients array to the shoppingList array (after an object has been added).
Your shoppingList is empty so its length = 0. The second loop of the array doesn't run, since it's told to run 0 times.
You don't need the second loop to add an object to the shoppingList, so I would remove it.
As others have said, shoppingList starts with a length of 0, so the 2nd loop will never run. Also, if you're trying to sum the quantity of items with the same name, you could use reduce to simplify things:
const ingredients = [
{ name: 'potatoes', quantity: 4 },
{ name: 'butter', quantity: 1 },
{ name: 'milk', quantity: 1, description: '1 cup' },
{ name: 'potatoes', quantity: 3 },
{ name: 'oil', quantity: 1, description: '3 cups' } ];
const result = ingredients.reduce((acc, curr) => {
const exists = acc.find(item => item.name === curr.name);
if (exists) {
exists.quantity += curr.quantity;
return acc;
}
return [...acc, curr]
}, []);
console.log(result);
You can use Array.prototype.reduce and ES6 object destructuring assignment to make an aggregation of ingredients by their name, and Array.prototype.map to generate the desired output:
This solution is more declarative than nested for loops and can work with any amount of repetitive items:
var ingredients = [
{ name: 'potatoes', quantity: 4 },
{ name: 'butter', quantity: 1 },
{ name: 'milk', quantity: 1, description: '1 cup' },
{ name: 'potatoes', quantity: 3 },
{ name: 'oil', quantity: 1, description: '3 cups' }
];
// Aggregate `quantity` by `name`
var dataObj = ingredients.reduce((all, {name, quantity}) => {
all[name] = (all[name] || 0) + quantity;
return all;
}, {});
// Generate the result
var shoppingList = Object.keys(dataObj).map(ing => ({name: ing, quantity: dataObj[ing]}));
console.log(shoppingList);

ES6 Nested Grouping with Sum

I have an array like this:
[{
id: 1,
amount: 10,
date: '21/01/2017'
},{
id: 1,
amount: 10,
date: '21/01/2017'
},{
id: 1,
amount: 30,
date: '22/01/2017'
},{
id: 2,
amount: 10,
date: '21/01/2017'
},]
And I would like this to output like:
{
'21/01/2017': {
1: {
amount: 20
},
2: {
amount: 10
}
},
'22/01/2017': {
1: {
amount: 30
}
}
}
Essentially grouping by data, nested grouping by id, whilst summing the relevant amounts.
So far I have tried using reduce, and have looked at lodash functions but have been unsuccessful. Reduce makes it easy to group by and sum by one prop, but I cannot find an elegant way to create the second level. I have created the a sandbox to demonstrate:
https://codesandbox.io/s/woj9xz6nq5
const dataToConsolidate = [{
id: 1,
amount: 10,
date: '21/01/2017'
}, {
id: 1,
amount: 10,
date: '21/01/2017'
}, {
id: 1,
amount: 30,
date: '22/01/2017'
}, {
id: 2,
amount: 10,
date: '21/01/2017'
},]
export const consolidate = (data) => {
const result = data.reduce(function (res, obj) {
(!(obj.date in res)) ?
res.__array.push(res[obj.date] = obj) :
res[obj.date].amount += obj.amount;
return res;
}, { __array: [] });
return result;
};
console.log(consolidate(dataToConsolidate))
You could take the object as hash table and add the properties with a default style.
var array = [{ id: 1, amount: 10, date: '21/01/2017' }, { id: 1, amount: 10, date: '21/01/2017' }, { id: 1, amount: 30, date: '22/01/2017' }, { id: 2, amount: 10, date: '21/01/2017' }],
result = {};
array.forEach(function (o) {
result[o.date] = result[o.date] || {};
result[o.date][o.id] = result[o.date][o.id] || { amount: 0 };
result[o.date][o.id].amount += o.amount;
});
console.log(result);
.as-console-wrapper { max-height: 100% !important; top: 0; }
Here's a relatively ES6-y solution using reduce:
const initial = [{ id: 1, amount: 10, date: '21/01/2017' }, { id: 1, amount: 10, date: '21/01/2017' }, { id: 1, amount: 30, date: '22/01/2017' }, { id: 2, amount: 10, date: '21/01/2017' }];
const parse = obj => obj.reduce((final, { date, id, amount }) => {
let group = final[date] = final[date] || {};
group[id] = group[id] || { amount: 0 };
group[id].amount += amount;
return final;
}, {});
const final = parse(initial);
console.log(final);
You'll want to add appropriate logic to handle missing/erroneous date, id, or amount keys.
As a fan of the library Ramda (disclaimer: I'm one of the authors), I might well use it to make everything a bit more explicit:
const {pipe, groupBy, prop, map, pluck, sum} = R
const data = [
{"amount": 10, "date": "21/01/2017", "id": 1},
{"amount": 10, "date": "21/01/2017", "id": 1},
{"amount": 30, "date": "22/01/2017", "id": 1},
{"amount": 10, "date": "21/01/2017", "id": 2}
]
const consolidate = pipe(
groupBy(prop('date')),
map(groupBy(prop('id'))),
map(map(pluck('amount'))),
map(map(sum))
)
console.log(consolidate(data))
.as-console-wrapper { max-height: 100% !important; top: 0; }
<script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.25.0/ramda.min.js"></script>
Of course using a library just to solve this problem is simply overkill, when something like the solution from #NinaSholz does just fine. But if you find several places where it could help, it might be worth looking into something like that.
you can do it thru _.groupBy:
const res = _.chain(data)
.groupBy('date')
.mapValues(dateObj => _.chain(dateObj)
.groupBy('id')
.mapValues(idObj => ({ amount: _.sumBy(idObj, 'amount') }))
.value()
)
.value();

Javascript functional programming / Passing parameters

I started experimenting with functional programming lately and I'm trying to convert an old module I have written using imperative programming.
Let's say I have two arrays of objects i.e
orders: [
{
idOrder: 1,
amount: 100,
customerId: 25,
},
{
idOrder: 2,
amount: 200,
customerId: 20,
}
]
customers: [
{
customerId: 20,
name: "John Doe",
orders: []
},
{
customerId: 25,
name: "Mary Jane",
orders: []
}
]
I want to push all the orders to their respective customer. Is there a clean way of doing it?
I have tried this , but obviously it doesn't work that way :
customers.orders = orders.filter((x) => {
if (x.customerId === customerId) {
customer.orders.push(x);
}
});
Thanks
You could use a Map and get all customers first and then push the orders to the customers.
var object = { orders: [{ idOrder: 1, amount: 100, customerId: 25 }, { idOrder: 2, amount: 200, customerId: 20 }], customers: [{ customerId: 20, name: "John Doe", orders: [] }, { customerId: 25, name: "Mary Jane", orders: [] }] },
map = object.customers.reduce((m, a) => m.set(a.customerId, a), new Map);
object.orders.forEach(a => map.get(a.customerId).orders.push(a));
console.log(object.customers);
Possible solution:
for (c of customers){
c.orders.push(orders.filter( function(o){ return o.customerId === c.customerId} ));
}
If you think of customers as your accumulator you can Reduce orders with customers as your initial value.
NOTE: this does mutate customers if you do not want this as a side-effect you would have to clone customers. Also there is not error handling for customerId not found.
var orders = [{ idOrder: 1, amount: 100, customerId: 25 }, { idOrder: 2, amount: 200, customerId: 20}];
var customers = [{ customerId: 20, name: "John Doe", orders: [] }, { customerId: 25, name: "Mary Jane", orders: [] } ];
var customers_orders = orders.reduce(
(accum, v) =>
{ accum.find(
c => c.customerId == v.customerId).orders.push(v);
return accum;
}, customers);
console.log(customers_orders);
You can write a function and pass it to reduce method and compose it with map
Just one things: once it's created, it may never change. You can user Object.assign and concat.
var customersWithOrders = customers.map(function(customer) {
var relatedOrders = orders.filter(function(order) { return order.customerId === customer.customerId })
return Object.assign(
customer,
{
orders: customer.orders.concat(relatedOrders)
}
)
})

Categories