Roll20 uses cookies to improve your experience on our site. Cookies enable you to enjoy certain features, social sharing functionality, and tailor message and display ads to your interests on our site and others. They also help us understand how our site is being used. By continuing to use our site, you consent to our use of cookies. Update your cookie preferences .
×
Create a free account

Button to add values in repeating sections

March 30 (1 week ago)

Hi all,

I'm hoping to add a button and a flag to one of my repeating sections.

The button is labeled "Spent" and the flag is labeled "Rest".

The idea is that each repeating section represents the spending of a portion of a pool of a refreshable resource. (how many days can that player travel through time, from their current location in time, up or down)

The act of Resting refreshes that pool, refilling all days spent.

Each entry in the repeating section represents a Event that may have cost some amount of that pool. I'm rounding up to minutes, and hopefully I can do the math in the background to make the UI show minutes, hours, days and years remaining in each repeating section.

So, if a player spends 21 days of their 365 day long pool, that should be reflected in the repeating section, along with how much remains.

The act of adding values in repeating sections gets very boggy very quickly, but I think I figured out a way to make it work.

The idea is that there's a button on each section. "Calculate" perhaps. This is a manual operation, and not triggered on a refresh or open or whatever.

This button adds all the values of "Spent" to be added, starting with the section in which the button was pressed, and going down the list of Events... UNTIL it hits a section where the flag "Rest" has been ticked on.

This should cause the calculations to stop, allowing each section between the button and the Rest to show how much of the pool was spent for each time travel jump in each section.

Where there is no value for Spent, it skips that section (many Events do not involve time travel at all).

Does this make sense? I can mock up a picture if it will help.

March 31 (6 days ago)

Edited March 31 (6 days ago)
Scott C.
Forum Champion
Sheet Author
API Scripter
Compendium Curator

If I'm understanding correctly, you want a repeating section that acts as a running log of time spent doing things? That's doable, but does require a bit of infrastructure to run. The HTML is dead simple, but the sheetworkers will get a little complicated. I know you asked for this with a button to do the calculation, but I'm also going to show it with the totals autocalculating.

So, assuming that we have the following html for our repeating section:

<fieldset class="repeating_log">
  <input type="text" name="attr_name">
  <label>
    <span>duration</span>
    <input type="number" name="attr_duration">
  </label>
  <label>
    <span>rest?</span>
    <input type="checkbox" name="attr_rest" value="1">
  </label>
  <label>
    <span>Elapsed</span>
    <input type="number" name="attr_elapsed" readonly>
  </label>
  <button type="action" name="act_calculate">!!CALCULATE!!</button>
</fieldset>

We can keep a running tally of elapsed time with the following javascript:

/**
* Orders a single ID array.
* @param {string[]} repOrder - Array of IDs in the order they are in on the sheet, typically from the corresponding _repOrder attribute.
* @param {string[]} IDs - Array of IDs to be ordered. Aka the default ID Array passed to the getSectionIDs callback
* @param {object} [attributes] - the object containing your results from getAttrs
* @returns {string[]} - The ordered id array
*/
const orderSection = function(repOrder,IDs=[], attributes){
  const attributeKeys = Object.keys(attributes);
  // create an array with the custom ordered IDs at the front followed by the unordered IDs in the back
  const idArr = [...repOrder.filter(v => v),...IDs.filter(id => !repOrder.includes(id.toLowerCase()))]
    // filter this array of IDs to remove old IDs from deleted rows that stay in the _reporder attribute
    .filter(id => {
      const exists = attributeKeys.some(k => k.includes(id));
      return exists;
    });
  return idArr;
};


const calculateTime = (event,attributes,orderedIDs,setObj) => {
  // extract the section and sourceID from the sourceAttribute. We'll need this for later work
  const [,section,sourceID] = event.sourceAttribute.toLowerCase().match(/(repeating_.+?)_(.+?)_/) || [];
  const sourceRow = `${section}_${sourceID}`;
      
  // Find where the section that triggered the listener is located in the IDarray
  const sourceIndex = orderedIDs.indexOf(sourceID)

  // separate all the IDs that are before the sourceID, not including the sourceID
  const beforeIDs = orderedIDs.slice(0,sourceIndex);
  
  // A function to work through the beforeIDs from back to front and total the elapsed time
  // This pattern is called a queue or burndown. We're going to use it here because it's more efficient that reversing the array and iterating over it normally
  const calcElapsed = (total = 0) => {
    // extract the last id in the list of IDs that come before our sourceID.
    const id = beforeIDs.pop();
    const row = `${section}_${id}`;
    // get the value of the rest checkbox and convert it to a number
    const isRest = +attributes[`${row}_rest`] || 0;
    
    if(isRest){
      // if the row we got from beforeIDs is a rest row, then we just return the total and we stop working the queue
      return total;
    }
    const duration = +attributes[`${row}_duration`] || 0;
    total+= duration;
    if(beforeIDs.length){
      // If there are more rows to check, then continue working the queue.
      return calcElapsed(total);
    }else{
      // otherwise we have our total and we just return it
      return total;
    }
  };
  const sourceRest = +attributes[`${sourceRow}_rest`] || 0;
  const sourceDuration = +attributes[`${sourceRow}_duration`] || 0;
  const useSourceDuration = sourceRest ?
    0 :
    sourceDuration
  // Call the calcElapsed function we defined above and get the total. However, if beforeIDs is empty, then we just set elapsed to the duration value of the source row
  setObj[`${section}_${sourceID}_elapsed`] = beforeIDs.length ?
    calcElapsed(useSourceDuration) :
    (+attributes[`${section}_${sourceID}_duration`] || 0);
    
  if(!sourceRest){
    //calculate any rows that might be dependent on this row.
    let runningTotal = setObj[`${section}_${sourceID}_elapsed`];
    // recalculate any values that might be dependent on this value
    for (let i = sourceIndex + 1; i < orderedIDs.length; i++){
      const iRow = `${section}_${orderedIDs[i]}`;
      const iDuration = +attributes[`${iRow}_duration`] || 0;
      const iRest = +attributes[`${iRow}_rest`] || 0;
      runningTotal += iDuration;
      setObj[`${iRow}_elapsed`] = runningTotal;
      if(iRest){
        break;
      }
    }
  }
};

const getLogAttrs = (event,callback) => {
  // extract the section and sourceID from the sourceAttribute. We'll need this for later work
  const [,section,sourceID] = event.sourceAttribute.match(/(repeating_.+?)(?:_(.+?))?(?:$|_)/) || [];
  // Get the row IDs of each row in the section so that we can get all possibly relevant data.
  getSectionIDs(section,idArray => {
    // assemble an array of attribute names to get from the array of ids in the section. We'll also get the reporder of the section so that we can sort these ids by their actual position in the section.
    const getArr = idArray.reduce((arr,id) => {
      // push the name of each repeating attribute we want onto the array that is being assembled
      ['duration','rest'].forEach(attr => arr.push(`${section}_${id}_${attr}`))
      return arr;
    },[`_reporder_${section}`]);
    // use the getArr to query the R20 database for the values
    getAttrs(getArr,attributes => {
      // an empty object that we'll use to collect the changes we want to commit to the sheet.
      const setObj = {};

      // turn the reporder attribute into an array
      const repOrder = attributes[`_reporder_${section}`]?.toLowerCase().split(/\s*,\s*/) || [];
      // Sort the IDs into their actual order in the section (handles any reordering that a user might have done).
      const orderedIDs = orderSection(repOrder,idArray,attributes);
      callback(event,attributes,orderedIDs,setObj);
      setAttrs(setObj);
    });
  });
};
// On these events, call our calculateTime function

// Un comment the below listener to enable the calculation button
/*
on('clicked:repeating_log:calculate',(event) => {
  getLogAttrs(event,calculateTime);
})
*/

// comment out the below listeners to disable the autocalculation

on('change:repeating_log:rest change:repeating_log:duration clicked:repeating_log:calculate',(event) => {
  getLogAttrs(event,calculateTime);
});

on('remove:repeating_log change:_reporder:log',(event) => {
  getLogAttrs(event,(e,attributes,orderedIDs,setObj) => {
    // This is not the most efficient handling for removals and order changes as it will cause some duplicate calculating of elapsed, but it will work just fine and shouldn't cause any performance issues unless the logs get to be truly massive
    orderedIDs.forEach(id => {
      const pseudoEvent = {sourceAttribute:`repeating_log_${id}_`};
      calculateTime(pseudoEvent,attributes,orderedIDs,setObj);
    });
  });
});

Let me know if the comments need more elaboration or if I misunderstood what you were trying to do.

Here's what it looks like in action with the autocalculation.


April 01 (6 days ago)

based on your video, this looks really good. I'll try it later. Thanks.