1
votes

I have a budget spreadsheet with tabs for every pay period. These tabs are created as needed and don't have names I can easily know in advance. For instance, one will be "10/15 - 10/28" because that's the pay period. Next month I create a new one with "10/29 - 11/11." I'd like to be able to sum a value across all sheets. For example, every sheet has a row named "Save," some sheets have a row named "Rent", but not every sheet will contain rows with those names and when they do they won't always be in the same cell number.

Sample sheet

I've seen some examples where there's a bunch of SUMIFs and every sheet is manually named but I'd much rather not have to do that because this sheet gets copied fairly often and the sheet names will never be the same.

=SUMIFS('Tab 1' !A1:A10, 'Tab 1'!B1:B10, "Rent")
+SUMIFS('Tab 2' !A1:A10, 'Tab 2'!B1:B10, "Rent")
+SUMIFS('Tab 3' !A1:A10, 'Tab 3'!B1:B10, "Rent")

Is this possible with either a standard formula or a script?

Sample Data

Tab 1

Tab 2

Tab 3

Desired final tab

Summaries Tab

Column 1's values are known in advance so those can be hardcoded. For instance, there will never be a random "yet more stuff" appear which I wouldn't sum up by adding a new row to the final tab.

2
If you rename a tab, the function's tab name string also gets renamed automatically. - TheMaster
Can you provide a copy of the spreadsheet you are working on, indicating the formulas used on what can change in a sheet? (name, cell values, etc.) - Iamblichus
Why create a new tab every pay period? Why not one tab for an entire year's worth of pay periods? - MattKing
@TheMaster I'm adding tabs so hardcoding them in the function doesn't work. - Ginger McMurray

2 Answers

2
votes

While there's another answer that works for this, I think the use of text finders and getRange, getValue and setFormula in loops is not the best approach, since it greatly increases the amount of calls to the spreadsheet service, slowing down the script (see Minimize calls to other services).

Method 1. onEdit trigger:

An option would be to use an onEdit trigger to do the following whenever a user edits the spreadsheet:

  • Loop through all sheets (excluding Totals).
  • For each sheet, loop through all data.
  • For each row, check if the category has been found previously.
  • If it has not been found, add it (and the corresponding amount) to an array storing the totals (called items in the function below).
  • If it has been found, add the current amount to the previous total.
  • Write the resulting data to Totals.

It could be something like this (check inline comments for more details):

const TOTAL_SHEET_NAME = "Totals";
const FIRST_ROW = 4;

function onEdit(e) {
  const ss = e.source;
  const targetSheet = ss.getSheetByName(TOTAL_SHEET_NAME);
  const sourceSheets = ss.getSheets().filter(sheet => sheet.getName() !== TOTAL_SHEET_NAME);
  let items = [["Category", "Amount"]];
  sourceSheets.forEach(sheet => { // Loop through all source sheets
    const values = sheet.getRange(FIRST_ROW, 1, sheet.getLastRow()-FIRST_ROW+1, 2).getValues();
    values.forEach(row => { // Loop through data in a sheet
      const [category, amount] = row;
      const item = items.find(item => item[0] === category); // Find category
      if (!item) { // If category doesn't exist, create it
        items.push([category, amount]);
      } else { // If category exists, update the amount
        item[1] += amount;
      }
    });
  });
  targetSheet.getRange(FIRST_ROW-1, 1, items.length, items[0].length).setValues(items);
}

Method 2. Custom function:

Another option would be to use an Apps Script Custom Function.

In this case, writing the data via setValues is not necessary, returning the results would be enough:

const TOTAL_SHEET_NAME = "Totals";
const FIRST_ROW = 4;

function CALCULATE_TOTALS() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sourceSheets = ss.getSheets().filter(sheet => sheet.getName() !== TOTAL_SHEET_NAME);
  let items = [["Category", "Amount"]];
  sourceSheets.forEach(sheet => { // Loop through all source sheets
    const values = sheet.getRange(FIRST_ROW, 1, sheet.getLastRow()-FIRST_ROW+1, 2).getValues();
    values.forEach(row => { // Loop through data in a sheet
      const [category, amount] = row;
      const item = items.find(item => item[0] === category); // Find category
      if (!item) { // If category doesn't exist, create it
        items.push([category, amount]);
      } else { // If category exists, update the amount
        item[1] += amount;
      }
    });
  });
  return items;
}

Once the script is saved, you can use this function the same you would use any sheets built-in function:

enter image description here

The problem with this approach is that the formula won't recalculate automatically when changing any of the source data. In order to do that, see the above method.

Method 3. onSelectionChange trigger:

From your comment:

I'd love to be able to trigger it when the totals sheet is opened but that doesn't appear to be possible

You can do this by using an onSelectionChange trigger in combination with PropertiesService.

The idea would be that, every time a user changes cell selection, the function should check whether current sheet is Totals and whether the previously active sheet is not Totals. If that's the case, this means the user just opened the Totals sheet, and the results should update.

It could be something like this:

function onSelectionChange(e) {
  const range = e.range;
  const sheet = range.getSheet();
  const sheetName = sheet.getName();
  const previousSheetName = PropertiesService.getUserProperties().getProperty("PREVIOUS_SHEET");
  if (sheetName === TOTAL_SHEET_NAME && previousSheetName !== TOTAL_SHEET_NAME) {
    updateTotals(e);
  }
  PropertiesService.getUserProperties().setProperty("PREVIOUS_SHEET", sheetName);
}

function updateTotals(e) {
  const ss = e.source;
  const targetSheet = ss.getSheetByName(TOTAL_SHEET_NAME);
  const sourceSheets = ss.getSheets().filter(sheet => sheet.getName() !== TOTAL_SHEET_NAME);
  let items = [["Category", "Amount"]];
  sourceSheets.forEach(sheet => { // Loop through all source sheets
    const values = sheet.getRange(FIRST_ROW, 1, sheet.getLastRow()-FIRST_ROW+1, 2).getValues();
    values.forEach(row => { // Loop through data in a sheet
      const [category, amount] = row;
      const item = items.find(item => item[0] === category); // Find category
      if (!item) { // If category doesn't exist, create it
        items.push([category, amount]);
      } else { // If category exists, update the amount
        item[1] += amount;
      }
    });
  });
  targetSheet.getRange(FIRST_ROW-1, 1, items.length, items[0].length).setValues(items);
}

Note: Please notice that, in order for this trigger to work, you need to refresh the spreadsheet once the trigger is added and every time the spreadsheet is opened (ref).

Reference:

0
votes

I wrote 2 scripts:

  1. budgetTotal which takes a budgetCategory parameter, for example "Rent", and loops through all the sheets in the file to sum up the amounts listed on each sheet for that category.
  2. budgetCreation which looks at your Totals sheet and writes these budgetTotal formulas in for each category you have listed.

I ran into a challenge which was, as I added new sheets the formulas wouldn't be aware and update the totals. So, what I did was create a simple button that executes the budgetCreation script. This way, as you add new payroll weeks you just need to press the button and - voila! - the totals update.

There might be a better way to do this using onEdit or onChange triggers but this felt like a decent starting place.

Here's a copy of the sheet with the button in place.

const ws=SpreadsheetApp.getActiveSpreadsheet()
const ss=ws.getActiveSheet()
const totals=ws.getSheetByName("Totals")

function budgetCreation(){
  var budgetStart = totals.createTextFinder("Category").findNext()
  var budgetStartRow = budgetStart.getRow()+1
  var budgetEndRow = ss.getRange(budgetStart.getA1Notation()).getDataRegion().getLastRow()
  var budgetCategoies = budgetEndRow - budgetStartRow + 1
  ss.getRange(budgetStartRow,2,budgetCategoies,1).clear()

  for (i=0; i<budgetCategoies; i++){
    var budCat = ss.getRange(budgetStartRow+i,1).getValue()
    var budFormula = `=budgetTotal(\"${budCat}\")`
    ss.getRange(budgetStartRow+i,2).setFormula(budFormula)
  }
}

function budgetTotal(budgetCategory) {
  var sheets = ws.getSheets()
  var total = 0
  for (i=0; i<sheets.length; i++){
    if (sheets[i].getName() != totals.getName()){
      var totalFinder = sheets[i].createTextFinder(budgetCategory).findNext()
      if (totalFinder == null){
        total = 0
      } else {
        var totalValueFinder = sheets[i].getRange(totalFinder.getRow(),totalFinder.getColumn()+1).getValue()
        total += totalValueFinder
      }
    }
  }
  return total
}