/**
* @file Schedule table state and utilities
*
* @author Colin Rice
* @version 1.0.0
* @module table.js
*/
activeScheduleTables = new Map();
/**
* A class representing an advertising schedule table.
* Provides a container for table state to make handling table data easier.
* Also provides methods for building a table and inserting it into the DOM.
*/
class ScheduleTable {
fromDate = null;
toDate = null;
colHeadings = ["DAYPART", "ads/wk", "Length", "MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN", "RATE", "COST"];
defaultDayParts = ["Morning (7a-10a)", "Middays (10a-3p)", "Afternoons(3p-6:30p)", "Sa-Su 9a-2p", "M-Su 12M-12M Bonus"];
rowHeadings = ["+ Add Daypart", "Totals:"];
// Skip dayparts and ads/week
columnsToSkipAtStart = 2;
// Skip cost / other utility columns
columnsToSkipAtEnd = 3;
// Skip headers
rowsToSkipAtStart = 1;
// Skip weekly totals and 'add daypart'
rowsToSkipAtEnd = 2;
width = 0;
height = 0;
tableElement = null;
rowTotals = [];
columnTotals = [0, null, 0, 0, 0, 0, 0, 0, 0, null, 0];
/**
* Initializes a table with the provided date range.
* These dates should be obtained through getScheduleDate.
* prefillWithDefaultDayParts should not be set until the toggle is implemented.
* You must run insertTableToDOM to finish table setup.
*
* @constructor
* @param {*} fromDate - The start date of this schedule.
* @param {*} toDate - The end date of this schedule, inclusive.
* @param {*} prefillWithDefaultDayParts - Whether to add a list of default dayparts to the table.
*/
constructor(fromDate, toDate, prefillWithDefaultDayParts = true) {
this.fromDate = fromDate;
this.toDate = toDate;
if (prefillWithDefaultDayParts) {
this.rowHeadings = this.defaultDayParts.concat(this.rowHeadings);
}
this.width = this.colHeadings.length + this.columnsToSkipAtEnd - 1; // All columns + utilities (delete and calendar buttons)
this.height = this.rowHeadings.length;
// Initialize an empty array for each row's totals
for (let i = 0; i < this.height; i++) {
this.rowTotals.push([]);
}
}
/**
* Displays the total of a row / column.
*
* @param {HTMLElement} target - The element to display the total on.
* @param {number} total - The total to display.
* @param {boolean} moneySign - Whether to
*/
displayTotal(target, total, moneySign = false)
{
// If there will be a money sign, then concatenate it to the amount
if (moneySign)
{
target.textContent = "$" + total;
} else // else, just put the amount only
{
target.textContent = total;
}
}
/**
* Updates the ads/week and cost fields for the row provided.
*
* @param {HTMLTableRowElement} singleRow - A row (td element) from the table.
* @param {number} rowIndex - The index of the row within the table.
*/
updateTotalsForRow(singleRow, rowIndex)
{
const cells = singleRow.children;
const totals = this.rowTotals[rowIndex];
const adsPerWeekCell = cells[1];
const adsPerWeekTotal = totals[0];
this.displayTotal(adsPerWeekCell, adsPerWeekTotal, false);
// Move the costcell back (because of utility divs)
const costCell = cells[cells.length - this.columnsToSkipAtEnd];
const costTotal = totals[totals.length - 1];
this.displayTotal(costCell, costTotal, true);
}
/**
* Calculates all necessary totals for a row and updates this.rowTotals with an array containing each column value including the final total.
* Runs updateTotalsForRow to update the ads/week and cost fields with the new totals.
*
* Example addition to this.rowTotals:
* ads/week, length, mon, tues, wed, thur, fri, sat, sun, rate, cost
* [19, 1.0, 1, 5, 2, 1, 1, 5, 4, 5, 95]
*
* @param {HTMLTableRowElement} singleRow - A row (td element) from the table.
* @param {number} rowIndex - The index of the row within the table.
*/
calculateAndUpdateTotalsForRow(singleRow, rowIndex)
{
const cells = singleRow.children;
let totals = this.rowTotals[rowIndex];
// Loop through the cells in the row, skipping special cells
for (let i = this.columnsToSkipAtStart; i < cells.length - this.columnsToSkipAtEnd; i++)
{
//Get the input field contained within the table element
let cell = cells[i].children[0];
// Get the value of the element
let value = parseFloat(cell.value);
//If the cell is empty use 0 as a placeholder
if (isNaN(value))
{
value = 0;
}
totals.push(value); // Add the value to the running total
}
// Create an array of just the ad counts
// by slicing off the first and last elements (length and rate)
let adCountsArray = totals.slice(1, -1);
// Get the total amount of ads for the week
let adsPerWeekTotal = adCountsArray.reduce((total, value) => total + value, 0);
// Calculate cost by multiplying this by the rate
let cost = adsPerWeekTotal * totals.at(-1);
//Add these values to the array of totals
totals.unshift(adsPerWeekTotal);
totals.push(cost);
this.rowTotals[rowIndex] = totals;
this.updateTotalsForRow(singleRow, rowIndex);
}
/**
* Displays the totals for each column.
* Null values in this.columnTotals are ignored.
*/
displayColumnTotals()
{
let tbody = this.tableElement.children[0];
let rows = tbody.children;
for (let columnIndex = 0; columnIndex <= this.columnTotals.length; columnIndex++)
{
let finalColumnTotal = this.columnTotals[columnIndex];
// Skip null column totals
if (finalColumnTotal == null) {
continue;
}
let finalTotalCellIndex = columnIndex + 1; // We don't want to overwrite the "Weekly totals" header
// The weekly totals row is the last in the table. The cells containing the totals are its children
let finalTotalCellToUpdate = rows[rows.length - 1].children[finalTotalCellIndex];
if (finalTotalCellIndex == this.columnTotals.length) { // Display a dollar sign in the cost total
this.displayTotal(finalTotalCellToUpdate, finalColumnTotal, true);
} else {
this.displayTotal(finalTotalCellToUpdate, finalColumnTotal, false);
}
}
}
updateCampaignTotal()
{
// get the campaign total in the header
const totalDisplay = document.getElementById("campaign-total");
// if it doesn't exist, stop the function
if (!totalDisplay) {
return;
}
// keep track of the the total cost of all schedules
let campaignTotal = 0;
// retrieve all active ScheduleTable instances
const tables = activeScheduleTables.values();
for (const table of tables)
{
campaignTotal += table.columnTotals[table.columnTotals.length - 1];
}
//display the final campaign total in the header
totalDisplay.textContent = "$" + campaignTotal;
}
/**
* Calculates and displays totals for the table.
*/
getAllTotals()
{
//Rows are wrapped in a tbody element
let tbody = this.tableElement.children[0];
let rows = tbody.children;
//Clear rowTotals and columnTotals
this.rowTotals = [];
this.columnTotals = [0, null, 0, 0, 0, 0, 0, 0, 0, null, 0];
for (let i = 0; i < this.height; i++) {
this.rowTotals.push([]);
}
// Loop through each row in the table, skipping headers and final totals
for (let rowIndex = this.rowsToSkipAtStart; rowIndex < rows.length - this.rowsToSkipAtEnd; rowIndex++)
{
// Calculate the totals for this row
this.calculateAndUpdateTotalsForRow(rows[rowIndex], rowIndex);
// Add totals for each column to column totals
for (let totalIndex = 0; totalIndex < this.rowTotals[rowIndex].length; totalIndex++) {
let rowTotal = this.rowTotals[rowIndex][totalIndex];
// Skip null column totals (we don't need to calculate a total for this column)
if (this.columnTotals[totalIndex] == null)
{
continue;
}
this.columnTotals[totalIndex] += rowTotal;
}
}
this.displayColumnTotals();
this.updateCampaignTotal();
}
/**
* Creates a dropdown menu for ad length.
*/
createAdLengthDropdown()
{
const selection = document.createElement("select");
const values = [":60", ":30", ":15", ":10"];
for (let i = 0; i < values.length; i++)
{
const option = document.createElement("option");
option.textContent = values[i];
selection.append(option);
}
return selection;
}
/**
* Creates the table rows.
*/
createTrElements()
{
const trs = [];
// Add an extra row for headings
for (let i = 0; i < this.rowHeadings.length+1; i++)
{
trs.push(document.createElement("tr"));
}
// Return that array of tr elements
return trs;
}
/**
* Populates the first table row with column headings.
*
* @param {HTMLTableRowElement} firstTrEle - HTML element for the first row of the table
* @param {HTMLDivElement} generateScheduleElement - The closest button for generating a new schedule
*/
populateFirstTr(firstTrEle, generateScheduleElement)
{
const weekArea = generateScheduleElement.querySelector(".week-selection");
const weekOf = generateScheduleElement.querySelector(".week-of");
let dates = [];
if (weekArea.style.display == "flex")
{
let startDate = getScheduleDate(generateScheduleElement, true)[0];
// Loop 7 times (Monday -> Sunday)
for (let i = 0; i < 7; i++)
{
//make a new copy of monday
const currentDate = new Date(startDate);
//move forward however many days we are into the week
currentDate.setDate(startDate.getDate() + i);
// Push formatted dates into the array
// Example:
// "5/25"
dates.push(
currentDate.toLocaleDateString("en-US", {
month: "numeric",
day: "numeric"
})
);
}
}
for (let i = 0; i < this.colHeadings.length; i++)
{
const thEle = document.createElement("th");
if (weekArea.style.display == "flex")
{
// Use innerHTML instead of textContent
// so we can add line breaks inside the table headings
thEle.innerHTML = this.colHeadings[i];
// If this is one of the weekday columns,
// put the date above the weekday label
// Example:
// 5/25
// MO
if (i >= 3 && i <= 9)
{
thEle.innerHTML = dates[i - 3] + "<br>" + this.colHeadings[i];
}
}
else
{
thEle.textContent = this.colHeadings[i];
}
// Add the new heading to the row
firstTrEle.append(thEle);
}
}
/**
* Populates a table cell with content.
*
* @param {HTMLTableDataCellElement} tdEle - The table cell to populate.
* @param {number} row - The index of the row this cell is in.
* @param {number} col - The index of the column this cell is in.
* @returns {void}
*/
populateTableElement(tdEle, row, col)
{
let isAdsPerWeekField = (col == 1);
let isAdLengthField = (col == 2);
let isCostField = (col == 11);
let isSpecialRow = (row == this.height || row == this.height - 1);
// If it's the second to last cell in the row, add in the delete div
if (col == 12)
{
// Append the div with the td element (because that will be removed when clicked)
tdEle.append(generateDeleteDiv("tr"));
}
else if (col == 13)
{
tdEle.append(generateCreateEvent());
}
else if (isSpecialRow)
{
return;
}
//Add ad length dropdown
else if (isAdLengthField)
{
tdEle.append(this.createAdLengthDropdown());
}
//Create a blank span for ads/week
else if (isAdsPerWeekField)
{
tdEle.append(createElement("span", null, "ads", "0"));
}
//If it's not a special element or already filled in it's a regular input field
else if (!isCostField)
{
// Make a input field, give a type of number and min of 0
const inputEle = document.createElement("input");
inputEle.type = "number";
inputEle.min = "0";
// Append it to the td element
tdEle.append(inputEle);
}
}
/**
* Populates a single row with cells
*
* @param {HTMLElement} rowToPopulate - The row to populate.
* @param {number} rowIndex - The index of the row to populate.
* @param {boolean} isNewRow - Whether the row is being added after the table was initially built.
* @returns A complete row
*/
populateRow(rowToPopulate, rowIndex, isNewRow = false)
{
let dayPartValue = "";
let dayPartPlaceholder = "";
if (isNewRow)
{
dayPartValue = "";
dayPartPlaceholder = "New Daypart";
}
else
{
dayPartValue = this.rowHeadings[rowIndex - 1];
dayPartPlaceholder = "";
}
// For each column
for (let col = 0; col < this.width; col++)
{
// Make a td element
const tdEle = document.createElement("td");
let isSpecialRow = (rowIndex == this.height || rowIndex == this.height - 1);
let isAddDaypartRow = (rowIndex == this.height - 1);
// Don't add utilities to special rows
if (isSpecialRow && col >= 12) { continue; }
if (col == 0)
{
// Don't make the weekly totals row editable
if (isSpecialRow && !isAddDaypartRow)
{
tdEle.textContent = dayPartValue;
}
else
{
const dayPartField = createElement("textarea", null, "daypart-input");
dayPartField.value = dayPartValue;
dayPartField.placeholder = dayPartPlaceholder;
dayPartField.rows = 1;
// If this is the fake "+ Add Daypart" row,
// make it readonly and style it like a button
if (this.rowHeadings[rowIndex - 1] === "+ Add Daypart" && !isNewRow)
{
dayPartField.readOnly = true;
dayPartField.classList.add("add-daypart-button");
// When this fake button row is clicked,
// run the add row function
dayPartField.addEventListener("click", handleAddDaypartRow);
rowToPopulate.classList.add("add-daypart-row");
}
dayPartField.addEventListener("input", handleResizeDaypartTextarea);
tdEle.append(dayPartField);
}
// Add styling for daypart column
tdEle.classList.add("time-slot");
}
else
{
this.populateTableElement(tdEle, rowIndex, col);
}
rowToPopulate.append(tdEle);
}
return rowToPopulate;
}
/**
* Creates and returns the table.
*
* @param {HTMLElement} generateScheduleElement - The closest element with the ".generate-new-schedule" tag.
* @returns {HTMLTableElement} The new table element.
*/
createWholeTable(generateScheduleElement)
{
const table = document.createElement("table")
const tableBody = document.createElement("tbody");
// Append tableBody to table
table.append(tableBody);
// Create the tr elements for the table
const trEles = this.createTrElements();
// Populate the first tr with the columns
this.populateFirstTr(trEles[0], generateScheduleElement);
// Populate the rest of the rows
for (let row = 1; row < trEles.length; row++)
{
trEles[row] = this.populateRow(trEles[row], row);
}
// Add each row to the table
for (let i = 0; i < trEles.length; i++)
{
tableBody.append(trEles[i]);
}
activeScheduleTables.set(table, this);
this.tableElement = table;
// Return that table
return table;
}
/**
* Creates and wraps a table, then returns it.
*
* @param {HTMLElement} element - The closest element with the ".generate-new-schedule" tag.
* @returns {HTMLDivElement} A container div for the table, containing the table date range and the table itself.
*/
buildTable(element)
{
// Create a container (div)
const container = createElement("div", null, "table-container")
// Create a div for the type of schedule h3 heading
const h3Wrapper = createElement("div", null, "schedule-type-wrapper");
// Create a h3 heading with Type of Schedule text
const headingThree = createElement("h3", null, "schedule-type", getScheduleDate(element));
// Append the h3 to the h3Wrapper div
h3Wrapper.append(headingThree);
// Append the h3 and the table to the div
container.append(h3Wrapper, this.createWholeTable(element));
// Return the table
return container;
}
/**
* Builds and inserts the table into the DOM.
* Sets this.tableElement and adds the table to activeScheduleTables.
* The table is inserted above the element passed in.
*
* @param {HTMLElement} element - The closest element with the ".generate-new-schedule" tag.
*/
insertTableToDOM(element)
{
// Build the table for that schedule
const newTable = this.buildTable(element);
//Add event listeners to the table
newTable.addEventListener("input", handleInputEventForSchedules);
newTable.addEventListener("paste", handlePasteEventForSchedules);
newTable.addEventListener("keydown", handleKeyDownEventForSchedules);
// Insert the table ABOVE the element (or before this element comes up)
element.parentNode.insertBefore(newTable, element);
}
}