diff --git a/blocks/gmo-program-details/gmo-program-details.css b/blocks/gmo-program-details/gmo-program-details.css index 801d8c9d..36a77d6f 100644 --- a/blocks/gmo-program-details/gmo-program-details.css +++ b/blocks/gmo-program-details/gmo-program-details.css @@ -102,6 +102,37 @@ body { } } +.header-row3 .icon-release-tier { + width: 16px; + height: 16px; + background-image: url('/icons/launch.svg'); + background-size: contain; + background-repeat: no-repeat; + margin-right: 8px; + margin-left: 16px; +} + +.header-row3 .icon-productGroup { + width: 16px; + height: 16px; + background-image: url('/icons/folderOpenOutline.svg'); + background-size: contain; + background-repeat: no-repeat; + margin-right: 8px; + margin-left: 16px; +} + +.header-row3 .release-tier { + font: normal normal normal 14px/21px Adobe Clean; + margin-left: 8px; +} + +.header-row3 .productGroup { + font: normal normal normal 14px/21px Adobe Clean; + margin-left: 8px; +} + + .details-header-wrapper .header-row3 { display: flex; align-items: center; @@ -136,7 +167,7 @@ body { justify-content: space-between; flex-wrap: wrap; align-content: center; - width: 160px; + width: 240px; height: 50px; font: normal normal normal 14px/17px Adobe Clean; border-bottom: 2px solid #EAEAEA; @@ -156,7 +187,425 @@ body { flex-direction: row; justify-content: space-between; } +.tab.calendar { + margin-top:10px; + & > .no-data-msg { + height: 100px; + margin-top: 15px; + display: flex; + align-items: center; + } + & > .control-wrapper { + height: 32px; + padding-top: 1px; + padding-bottom: 1px; + margin-bottom: 15px; + display: flex; + flex-direction: row; + justify-content: space-between; + & .inc-dec-wrapper { + font: normal normal normal 14px/17px Adobe Clean; + display: flex; + flex-direction: row; + width: 140px; + + & .year-switch { + cursor: pointer; + display: flex; + flex-direction: row; + width: 64px; + box-shadow: 0px 1px 4px #00000026; + border-radius: 4px; + & .year-toggle { + display: flex; + justify-content: center; + align-content: center; + height: 32px; + width: 32px; + flex-wrap: wrap; + } + & img { + height: 15px; + &.left { + transform: rotate(180deg); + } + } + &.disabled { + background-color: #c6c6c6; + cursor: default; + } + } + & .current-year { + line-height: 32px; + margin-left: 20px; + &.single { + margin-left: unset; + } + } + } + & .right-controls { + width: 240px; + display: flex; + & .today-button { + width: 66px; + height: 32px; + font: normal normal normal 14px/32px Adobe Clean; + border-radius: 4px; + box-shadow: 0px 1px 4px #00000026; + display: flex; + justify-content: center; + margin-right: 5px; + cursor: pointer; + } + } + & .filter-dropdown-wrapper { + position: relative; + display: inline-block; + font: normal normal normal 14px/17px Adobe Clean; + letter-spacing: 0px; + height: 32px; + background-color: #FFF; + width: 70%; + border: 1px solid #D3D3D3; + border-radius: 4px; + cursor: pointer; + & > .filter-dropdown-button { + height: 32px; + line-height: 32px; + padding-left: 10px; + display: flex; + justify-content: space-between; + & > .label { + overflow: hidden; + } + & > .icon { + padding-top: 4px; + height: 16px; + } + } + & > .filter-dropdown-content { + display: none; + position: absolute; + background-color: #f9f9f9; + + max-height: 200px; + overflow-y: auto; + border: 1px solid #ccc; + z-index: 2; + width: 100%; + } + &.active .filter-dropdown-content { + display: block; + } + & .filter-option, & .filter-label { + display: block; + overflow-x: hidden; + + text-decoration: none; + color: #333; + } + & .filter-option { + padding: 10px 0px 10px 30px; + &:hover { + background-color: #ddd; + } + } + & .filter-label { + padding: 10px 0px 10px 10px; + font-weight: bold; + } + } + } + & > .calendar-wrapper { + overflow-y: auto; + overflow-x: hidden; + background-color: white; + font: normal normal bold 14px/21px Adobe Clean; + display: flex; + flex-direction: column; + border-bottom: 3px solid #F4F4F4; + position: relative; + min-height: 500px; + & .header-wrapper { + height: 24px; + & .quarter-header { + display: flex; + flex-direction: row; + height: 24px; + } + & .quarter { + width: 25%; + display: flex; + justify-content: center; + background-color: #F4F4F4; + + &:not(:first-child) { + border-left: 3px solid white; + } + } + } + &.quarter-view, &.multiyear { + overflow-x: unset; + } + & .calendar-background { + height: 100%; + display: flex; + flex-direction: row; + position: absolute; + top: 0; + bottom: 0; + &.quarter-view { + width: 200%; + } + } + & .month-wrapper, & .quarterview-wrapper { + height: 100%; + width: 100%; + display: flex; + flex-direction: row; + & .month { + width: 8.3%; + display: flex; + justify-content: flex-start; + flex-direction: column; + align-items: center; + border-left: 3px solid #F4F4F4; + height:100%; + &:last-child { + border-right: 3px solid #F4F4F4; + } + & .label { + padding: 3px; + margin-top: 1px; + } + &.current .label { + background-color: #96d7ff; + color: #2680EB; + border-radius: 15px; + padding-right: 15px; + padding-left: 15px; + z-index: 1; + + } + & .calendar-indicator { + width: 1px; + height: 100%; + margin-top: -13%; + background: linear-gradient( + to bottom, #2680EB 50%, transparent 50% + ); + background-size: 1px 5px; + } + } + } + & .calendar-content-wrapper { + position: relative; + padding-top: 65px; + padding-left: 5px; + width: 100%; + top: 0; + left: 0; + right: 0; + padding-bottom: 20px; + } + & .calendar-group { + width: 550px; + background-color: lightcyan; + border-radius: 7px; + &:not(:last-child) { + margin-bottom: 20px; + } + & .group-header { + position:relative; + height: 40px; + width: 100%; + color: white; + display: flex; + flex-direction: row; + flex-wrap: nowrap; /* Prevent wrapping */ + align-content: center; + justify-content: space-between; + border-radius: 7px 7px 0px 0px; + &.content-hidden { + border-radius: 7px; + } + & .left-block { + display: flex; + flex-direction: row; + align-items: center; /* Vertically center the items */ + flex-wrap: nowrap; /* Prevent wrapping within left-block */ + align-content: center; + border-radius: 7px 7px 0px 0px; + } + & .group-arrow { + transform: rotate(180deg); + filter: invert(99%) sepia(0%) saturate(0%) hue-rotate(227deg) brightness(113%) contrast(100%); + margin-right: 15px; + margin-top: 5px; + } + } + & .group-heading { + line-height: 24px; + height: 24px; + margin-left: 5px; + white-space: nowrap; /* Prevents the text from wrapping to a new line */ + overflow: hidden; /* Hides the overflow text */ + text-overflow: ellipsis; /* Adds an ellipsis (...) to indicate truncated text */ + max-width: 100%; /* Adjusts the width to leave space for the group count */ + display: inline-block; /* Ensures the element respects the max-width */ + vertical-align: middle; /* Aligns it properly with other inline elements */ + } + & .group-count { + position: absolute; + top: 50%; /* Align it vertically */ + right: 10px; /* Align it horizontally to the right */ + transform: translateY(-50%); /* Center the element vertically */ + display: inline-flex; + align-items: center; /* Vertically center the content */ + justify-content: center; /* Center the content horizontally */ + min-width: 24px; /* Set minimum width instead of fixed width */ + margin-left: 8px; + margin-right: 5px; /* Add margin if necessary to prevent overlapping */ + height: 24px; + border-radius: 10px; + line-height: 24px; + background-color: black; + background: rgba(0,0,0,0.2); + } + & .group-controls { + margin-left: 15px; + } + & .group-collapse { + transform: rotate(90deg); + } + & .group-content { + display: flex; + flex-direction: column; + + padding-left: 5px; + padding-right: 5px; + & .item { + display: flex; + flex-direction: row; + justify-content: flex-start; + background-color: white; + margin-bottom: 10px; + border-radius: 4px; + height: 64px; + min-width: 190px; + font-size: 12px; + &:first-child { + margin-top: 10px; + } + & .content-row { + height: 32px; + line-height: 32px; + &.bottom { + display: flex; + line-height: unset; + align-items: center; + & div:not(last-child) { + margin-right: 4px; + } + } + } + & .start-date, & .link { + border-radius: 5px; + border: 1px solid #a9a9a9; + height: 12px; + padding: 4px; + } + & .color-tab { + border-radius: 4px 0px 0px 4px; + background-color: blue; + width: 3px; + height: 100%; + } + & > .item-content { + display: flex; + flex-direction: column; + width: 96%; + line-height: 12px; + margin-left: 2px; + } + & .name { + max-width: 62%; + overflow-x: hidden; + text-overflow: ellipsis; + text-wrap: nowrap; + } + } + & .info { + display: flex; + flex-direction: row; + justify-content: flex-start; + } + & .thumbnail { + height: 32px; + width: 32px; + background-color: lightgray; + border-radius: 4px; + margin-right: 5px; + overflow: hidden; + } + & .link { + margin-right: 10px; + display: flex; + & > img { + margin-top: 1px; + margin-left: 5px; + transform: rotate(180deg); + } + } + & .item-status { + height: 5px; + width: 5px; + background-color: green; + border-radius: 50%; + position: relative; + margin-top: 13px; + margin-left: 10px; + } + } + &.color1 { + background-color: #33AB840D; + border: 1px solid #12805C; + & .group-header { + background-color: #33AB84; + } + } + &.color2 { + background-color: #37c8f029; + border: 1px solid #2680EB; + & .group-header { + background-color: #378EF0; + } + } + &.color3 { + background-color: #cf3edc2d; + border: 1px solid #A228AD; + & .group-header { + background-color: #CF3EDC; + } + } + &.color4 { + background-color: #9c64e126; + border: 1px solid #7A42BF; + & .group-header { + background-color: #9D64E1; + } + } + &#group5 { + background-color: #168a4a; + border: 1px solid #24a25d; + & .group-header { + background-color: #137941; + } + } + } + } +} .overview-wrapper { display: flex; flex-direction: column; @@ -177,6 +626,7 @@ body { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; + line-clamp: 3; overflow: hidden; } } @@ -213,7 +663,7 @@ body { & .tags-wrapper { display: flex; - flex-wrap: wrap; /* Ensure tags wrap properly */ + flex-wrap: wrap; margin-top: 10px; } @@ -222,12 +672,12 @@ body { color: #505050; border: 1px solid #D3D3D3; border-radius: 4px; - padding: 0 10px; /* Ensure no padding at the bottom */ + padding: 0 10px; line-height: 32px; - height: 32px; /* Ensure consistent height */ + height: 32px; display: flex; - align-items: center; /* Vertically center the text */ - margin-bottom: 5px; /* Add margin at the bottom */ + align-items: center; + margin-bottom: 5px; &:not(:last-child) { margin-right: 5px; } @@ -341,32 +791,32 @@ body { .total-assets-tooltip .tooltiptext { visibility: hidden; - white-space: nowrap; /* Ensure text appears on one line */ - background-color: #D3D3D3; /* Match the background color */ - color: black; /* Match the text color */ - text-align: left; /* Left-align the text */ + white-space: nowrap; + background-color: #D3D3D3; + color: black; + text-align: left; border-radius: 4px; - padding: 5px 10px; /* Adjust padding as needed */ + padding: 5px 10px; position: absolute; z-index: 1; - top: 50%; /* Center vertically */ - left: 100%; /* Position to the right of the element */ - transform: translateY(-50%); /* Center vertically */ - margin-left: 10px; /* Adjust margin to match the screenshot */ + top: 50%; + left: 100%; + transform: translateY(-50%); + margin-left: 10px; opacity: 0; transition: opacity 0.3s; - box-shadow: 0px 3px 6px rgba(0, 0, 0, 0.1); /* Add shadow for better visibility */ + box-shadow: 0px 3px 6px rgba(0, 0, 0, 0.1); } .total-assets-tooltip .tooltiptext::after { content: ""; position: absolute; - top: 50%; /* Center the arrow vertically */ - left: -5px; /* Adjust to position the arrow */ - transform: translateY(-50%); /* Center vertically */ + top: 50%; + left: -5px; + transform: translateY(-50%); border-width: 5px; border-style: solid; - border-color: transparent #D3D3D3 transparent transparent; /* Arrow color matches tooltip background */ + border-color: transparent #D3D3D3 transparent transparent; } .total-assets-tooltip:hover .tooltiptext { @@ -414,11 +864,11 @@ body { &.datarow { display: flex; align-items: center; - height: auto; /* Set to auto to accommodate varying heights */ + height: auto; font-weight: 200; & .property { - line-height: normal; /* Adjust line-height to normal */ - padding: 10px 0; /* Add padding for better spacing */ + line-height: normal; + padding: 10px 0; } &:first-child, &:nth-child(2) { border-top: 2px solid #F4F4F4; @@ -588,6 +1038,7 @@ body { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; + line-clamp: 3; overflow: hidden; text-overflow: ellipsis; } diff --git a/blocks/gmo-program-details/gmo-program-details.js b/blocks/gmo-program-details/gmo-program-details.js index 738f03b0..36c5f29a 100644 --- a/blocks/gmo-program-details/gmo-program-details.js +++ b/blocks/gmo-program-details/gmo-program-details.js @@ -10,14 +10,24 @@ const programName = queryVars.programName; const programID = queryVars.programID; const deliverableMappings = getMappingArray('deliverableType'); const platformMappings = getMappingArray('platforms'); +const taskStatusMappings = getMappingArray('taskStatus'); +const startDateProp = 'taskPlannedStartDate'; +const endDateProp = 'taskPlannedEndDate'; +let viewStart, viewEnd, calendarDeliverables; + +// Thumbnail cache array object to store the image objects using cacheKey = `${programName}-${campaignName}-${deliverableType}`; +const thumbnailCache = {}; export default async function decorate(block) { const encodedSemi = encodeURIComponent(';'); const encodedProgram = encodeURIComponent(programName); + const encodedPath = queryVars.path ? `${encodeURIComponent(queryVars.path)}` : ''; blockConfig = readBlockConfig(block); + // Including path in the query if present + const programQueryString = `getProgramDetails${encodedSemi}programName=${encodedProgram}${encodedSemi}programID=${encodeURIComponent(programID)}` + + (encodedPath ? `${encodedSemi}path=${encodedPath}` : ''); - const programQueryString = `getProgramDetails${encodedSemi}programName=${encodedProgram}${encodedSemi}programID=${encodeURIComponent(programID)}`; const deliverableQueryString = `getProgramDeliverables${encodedSemi}programName=${encodedProgram}${encodedSemi}programID=${encodeURIComponent(programID)}`; // Immediately render a placeholder header @@ -88,12 +98,14 @@ export default async function decorate(block) { const audiences = buildAudienceList(program).outerHTML; const artifactLinks = buildArtifactLinks(program).outerHTML; + const currentYear = new Date().getFullYear(); + // Inject the additional HTML content block.querySelector('.main-body-wrapper').innerHTML += `
Overview
Deliverables
-
Calendar
+
Calendar
@@ -177,6 +189,32 @@ export default async function decorate(block) {
+
+
+
+
+
+ +
+
+ +
+
+
${currentYear}
+
+
+
Today
+
+
+
Selected View: Year
+ + +
+
+
+
+
+ `; // Wait for deliverables data @@ -227,7 +265,7 @@ export default async function decorate(block) { }); decorateIcons(block); - + buildCalendar(await deliverables, block, "year", await deliverableMappings); } function enableBackBtn(block, blockConfig) { @@ -260,6 +298,18 @@ function buildHeader(program, queryVars) { driverField=buildDriverField(driver).outerHTML; } + const releaseTier = ` +
+ + Release Tier: ${program.releaseTier ? program.releaseTier : "Not Available"} +
`; + + const productGroup = ` +
+ + ${program.productGroup && program.productGroup.length > 0 ? program.productGroup.join(', ') : "Not Available"} +
`; + headerWrapper.innerHTML = `
@@ -271,6 +321,8 @@ function buildHeader(program, queryVars) {
${date} ${driverField} + ${releaseTier} + ${productGroup}
` @@ -659,22 +711,673 @@ function attachListener(htmlElement) { function extractQueryVars() { const urlStr = window.location.href; - const pnRegex = /.*programName=(.*?)&programID=(.*)/; + const pnRegex = /[?&]programName=([^&]+)&programID=([^&]+)(&path=([^&]+))?/; const match = urlStr.match(pnRegex); if (match && match[1] && match[2]) { - const pName = decodeURIComponent(match[1]); - let pID = decodeURIComponent(match[2]) + const pName = decodeURIComponent(match[1]); // Removed the replace method + let pID = decodeURIComponent(match[2]); + let pPath = match[4] ? decodeURIComponent(match[4]) : null; if (pID.endsWith('#')) { pID = pID.slice(0, -1); } return { programName: pName, - programID: pID - } + programID: pID, + path: pPath + }; } else { return { programName: 'Program Name Not Available', - programID: 'Program ID Not Available' + programID: 'Program ID Not Available', + path: null + }; + } +} + +// program calendar view +async function buildCalendar(dataObj, block, type, mappingArray, period) { + if (!calendarDeliverables) calendarDeliverables = dataObj.data.deliverableList.items; + if (!deliverableMappings) deliverableMappings = await mappingArray; + if (!taskStatusMappings) taskStatusMappings = await taskStatusMappings; + + const programLaunch = document.querySelector('span.campaign-date').textContent; + const programLaunchDate = new Date(programLaunch); + + // multiple of 3 for width of column when viewing in quarter mode. can change this + // by adjusting the multiple below, and also the % width of the calendar background + // when drawing a 'quarter' calendar. + + const columnWidth = (type === "year") ? 8.315 : (8.315 * 3); + const currentDate = new Date(); + const currentYear = currentDate.getFullYear(); + + // if there are no deliverables, display msg to user and end construction. + if (calendarDeliverables.length === 0) { + const calendarTab = document.querySelector('.calendar.tab'); + calendarTab.innerHTML = ` +
Required Data is Unavailable for this view.
+ `; + return; + } + + // get start of the view + viewStart = getTimeBounds(calendarDeliverables, "start", startDateProp); + viewStart = (!(isValidDate(viewStart)) || viewStart <= 0) ? programLaunchDate : viewStart; + const viewStartYear = viewStart.getUTCFullYear(); + + const displayYear = period ? period.year : viewStartYear; + const displayQuarter = period ? period.quarter : 1; + + const yearIndicator = block.querySelector('.inc-dec-wrapper .current-year'); + yearIndicator.dataset.year = displayYear; + yearIndicator.textContent = displayYear; + + // get end of the view + viewEnd = getTimeBounds(calendarDeliverables, "end", endDateProp); + if (!(isValidDate(viewEnd)) || viewEnd <= 0) { + viewEnd = new Date(viewStart); + viewEnd.setMonth(viewStart.getMonth() + 1); + } + const viewEndYear = viewEnd.getUTCFullYear(); + + // get array of all years to be included + let years = calendarYears(viewStartYear, viewEndYear); + + // disable increment/decrement if only one year in view + if (years.length === 1) { + document.querySelector('.inc-dec-wrapper > .year-switch').classList.add('disabled'); + } + + // build the calendar background here as we already know the period and style + let calendarEl; + if (type === "year") { + calendarEl = buildYearCal(years); + } else { + // if the last item ends in December we need another year to catch the Q1 n+1 finish + if (viewEnd.getUTCMonth = 11) { + years.push(viewEndYear + 1); + } + calendarEl = buildQuarterCal(years); + } + // get unique deliverable types + const uniqueGroups = getUniqueItems(calendarDeliverables, "deliverableType"); + + // set up the content wrapper + const contentWrapper = document.createElement('div'); + contentWrapper.classList.add('calendar-content-wrapper'); + if (type === "quarter") { + contentWrapper.classList.add('quarter-view'); + contentWrapper.dataset.view = "quarter"; + } else { + contentWrapper.dataset.view = "year"; + } + + var groupIndex = 1; + for (const group of uniqueGroups) { + const groupType = await lookupType(group, 'deliverable-type'); + // find all members of this group + const matchedItems = calendarDeliverables.filter(item => item.deliverableType === group); + + // find the earliest date- this is how we set the position for the group against the calendar + let earliestStartDate = getTimeBounds(matchedItems, "start", startDateProp); + earliestStartDate = (!(isValidDate(earliestStartDate)) || earliestStartDate <= 0) ? new Date(viewStart) : earliestStartDate; + let latestEndDate = getTimeBounds(matchedItems, "end", endDateProp); + latestEndDate = (!(isValidDate(latestEndDate)) || latestEndDate <= 0) ? new Date(viewEnd) : latestEndDate; + const startMonth = (earliestStartDate.getUTCMonth()); // getMonth returns 0-11 but this is desirable + const startDay = (earliestStartDate.getUTCDate() - 1); // if at start of month, we don't want to add any more margin + const endMonth = (latestEndDate.getUTCMonth()); + const endDay = (latestEndDate.getUTCDate() - 1); + + const groupStartYear = earliestStartDate.getUTCFullYear(); + const groupEndYear = latestEndDate.getUTCFullYear(); + + // accounting for different years in view + const startYearOffset = ((groupStartYear - viewStartYear) * 12); + const endYearOffset = ((groupEndYear - viewStartYear) * 12); + const totalDaysInMonth = new Date(Date.UTC(groupStartYear, startMonth, 0)).getUTCDate(); + const totalDaysInEndMonth = new Date(Date.UTC(groupEndYear, endMonth, 0)).getUTCDate(); + + const percentOfStartMonth = (startDay / totalDaysInMonth); + const percentOfEndMonth = (endDay / totalDaysInEndMonth); + const dayMargin = (percentOfStartMonth * columnWidth); + const endDayMargin = (percentOfEndMonth * columnWidth); + let startPosition = (((startYearOffset + startMonth) * columnWidth) + dayMargin).toFixed(2); + let endPosition = (((endYearOffset + endMonth) * columnWidth) + endDayMargin).toFixed(2); + + if (type === "quarter") { + startPosition = parseFloat(startPosition) + parseFloat(columnWidth); + endPosition = parseFloat(endPosition) + parseFloat(columnWidth); + } + + if (endMonth > 9) endPosition = endPosition - 0.35; + const widthOfGroup = (endPosition - startPosition); // width of group = start position + (day duration) + // calculate the duration of the group as that helps set the width of its members + const groupDuration = Math.floor((latestEndDate.getTime() - earliestStartDate.getTime()) / (1000 * 60 * 60 * 24)); + + const itemWrapper = document.createElement('div'); + itemWrapper.classList.add('group-content'); + + for (const item of matchedItems) { + const itemStartDate = (item[startDateProp]) ? new Date(item[startDateProp]) : viewStart; + const itemEndDate = (item[endDateProp]) ? new Date(item[endDateProp]) : viewEnd; + + const itemEndDateStr = itemEndDate ? itemEndDate.toLocaleDateString().split(',')[0] : null; + const itemDuration = Math.floor((itemEndDate.getTime() - itemStartDate.getTime()) / (1000 * 60 * 60 * 24)); + const itemDurationPct = ((itemDuration / groupDuration) * 100).toFixed(2); + + let daysDifference = Math.floor((itemStartDate.getTime() - earliestStartDate.getTime()) / (1000 * 60 * 60 * 24)); + const startPctDiff = ((daysDifference / groupDuration) * 100).toFixed(2); + let itemEl = document.createElement('div'); + itemEl.classList.add('item'); + itemEl.style.marginLeft = startPctDiff + '%'; + + // Find the corresponding color code from the taskStatusMappings array + const itemStatusMapping = await getTaskStatusMapping(item.taskStatus); + const { text: statusText = 'Unknown Status', 'color-code': colorCode = 'green' } = itemStatusMapping; + + // Create a placeholder for the thumbnail + itemEl.innerHTML = ` +
+
+
+
+
+
${item.deliverableName}
+
+
+
+
+
+ ${itemEndDateStr ? '
End Date: ' + itemEndDateStr + '
' : ''} + +
+
+ `; + itemEl.style.width = itemDurationPct + '%'; + + // Call the new function to fetch and add the thumbnail, ensuring sequential execution + await addThumbnailToItem(itemEl, item.programName, item.campaignName,item.deliverableType); + itemWrapper.appendChild(itemEl); + + }; + + //await lookupType(category, 'deliverable-type'); + const groupEl = document.createElement('div'); + groupEl.classList.add('calendar-group', `color${groupIndex}`); + groupEl.style.marginLeft = startPosition + '%'; + groupEl.style.width = widthOfGroup + '%'; + groupEl.innerHTML = ` +
+
+ + +
${groupType}
+
${matchedItems.length}
+
+
+
+
+ `; + groupEl.appendChild(itemWrapper); + groupEl.querySelectorAll('.group-controls').forEach((arrow) => { + arrow.addEventListener('click', showHideGroup); + }); + + contentWrapper.appendChild(groupEl); + groupIndex +=1; + + }; + + calendarEl.appendChild(contentWrapper); + block.querySelector('.calendar.tab').appendChild(calendarEl); + + // populate "filter" dropdown + const filterDropdown = document.createElement('div'); + filterDropdown.classList.add('filter-dropdown-content'); + const uniqueYears = getUniqueYears(calendarDeliverables); + const yearOptionLabel = document.createElement('div'); + yearOptionLabel.classList.add('filter-label'); + yearOptionLabel.textContent = 'Year'; + const quarterOptionLabel = document.createElement('div'); + quarterOptionLabel.classList.add('filter-label'); + quarterOptionLabel.textContent = 'Quarter'; + filterDropdown.appendChild(yearOptionLabel); + + // when choosing 'Quarter' the top left controls change to control the quarter in focus + // its kind of a zoomed in view. + uniqueYears.forEach((year) => { + const yearOption = document.createElement('div'); + yearOption.classList.add('filter-option'); + yearOption.dataset.year = year; + yearOption.textContent = year; + yearOption.addEventListener('click', (event) => filterDropdownSelection(event, viewStartYear, years.length)); + filterDropdown.appendChild(yearOption); + }); + filterDropdown.appendChild(quarterOptionLabel); + const quarters = [ 1, 2, 3, 4 ]; + quarters.forEach((quarter) => { + const quarterOption = document.createElement('div'); + quarterOption.classList.add('filter-option'); + quarterOption.dataset.period = quarter; + quarterOption.textContent = quarter; + //quarterOption.addEventListener('click', filterDropdownSelection); + quarterOption.addEventListener('click', (event) => filterDropdownSelection(event, viewStartYear, years.length)); + filterDropdown.appendChild(quarterOption); + }) + + const filterDropdownWrapper = block.querySelector('.filter-dropdown-wrapper'); + filterDropdownWrapper.appendChild(filterDropdown); + filterDropdownWrapper.querySelector('.filter-dropdown-button').addEventListener('click', (event) => toggleDropdown(event.target)); + + // scroll to the right + const calendarWrapper = document.querySelector('.calendar-wrapper') + const scrollPct = calculateScroll(type, viewStartYear, displayYear, displayQuarter, years.length); + document.addEventListener('DOMContentLoaded', scrollOnInit(calendarWrapper, scrollPct)); + + // indicator that shows current day/month + const currentMonth = currentDate.getMonth() + 1; + // calculate the percentage completion of the current month for the indicator offset + const totalDaysInMonth = new Date((new Date(currentYear, currentMonth, 1)) - 1).getDate(); + const percentOfMonth = (currentDate.getUTCDate() / totalDaysInMonth).toFixed(2) * 100; + const monthEl = block.querySelector(`.month-wrapper[data-year='${currentYear}'] .month[data-num='${currentMonth}']`); + monthEl.classList.add('current'); + const lineEl = document.createElement('div'); + lineEl.classList.add('calendar-indicator'); + // use direct style for offset + lineEl.style.marginRight = ((-2 * percentOfMonth) + 100) + '%'; + monthEl.appendChild(lineEl); + + // close dropdown listener for clicks outside open dropdown + document.querySelector('.gmo-program-details.block').addEventListener('click', dismissDropdown); + block.querySelectorAll('.year-switch > .year-toggle').forEach((control) => { + control.removeEventListener('click', changePeriod); + if (years.length > 1) { + control.addEventListener('click', changePeriod); + } + }); + block.querySelector('.right-controls .today-button').addEventListener('click', () => { + const calendarWrapper = document.querySelector('.calendar-wrapper') + // determine scroll pct for years. always use type = year because "today" doesn't care about quarters. + const yearScrollPct = calculateScroll("year", viewStartYear, currentYear, displayQuarter, years.length); + // determine how far through the current year we are + const now = new Date(); + now.setHours(0,0,0,0); + const dateDiff = Math.floor((now - new Date(now.getFullYear(), 0, 0)) / (1000 * 60 * 60 * 24)); + const dayPct = (((dateDiff) * ((1 / years.length) / 365)) * 100); + // get total scroll percent by adding year + day scroll pct + const totalScroll = (parseFloat(yearScrollPct) + parseFloat(dayPct)).toFixed(2); + // scroll to position + scrollToPosition(calendarWrapper, totalScroll); + }); +} + +// calendar view supporting functions + +// Helper function to get task status mapping +async function getTaskStatusMapping(taskStatus) { + const taskStatusArray = await taskStatusMappings; + return taskStatusArray.find(mapping => mapping.value === taskStatus) || {}; +} + +async function addThumbnailToItem(itemEl, programName, campaignName, deliverableType) { + // Create a unique key for the cache based on the parameters, only add the campaignName in cacheKey when it is not null or empty + const cacheKey = campaignName ? `${programName}-${campaignName}-${deliverableType}` : `${programName}-${deliverableType}`; + + // Check if the imageObject is already cached + let imageObject = thumbnailCache[cacheKey]; + + // If not cached, make the API call and store the result in the cache + if (!imageObject) { + try { + imageObject = await searchAsset(programName, campaignName, deliverableType); + thumbnailCache[cacheKey] = imageObject; // Store the result in the cache + } catch (error) { + console.error("Failed to load thumbnail image:", error); + return; // Exit the function if the API call fails } } + + // Use the cached or newly fetched imageObject + if (imageObject && imageObject.imageUrl) { + const thumbnailDiv = itemEl.querySelector('.thumbnail'); + const imgElement = document.createElement('img'); + imgElement.src = imageObject.imageUrl; + imgElement.alt = imageObject.imageAltText; + imgElement.loading = 'lazy'; + thumbnailDiv.appendChild(imgElement); + } else { + console.error("Image Object does not have a valid imageUrl"); + } +} + +function getTimeBounds(items, whichEnd, property) { + const desiredDate = items.reduce((dateCompare, currentItem) => { + const currentItemDate = new Date(currentItem[property]); // Ensure UTC + if (whichEnd === "start") { + return currentItemDate < dateCompare ? currentItemDate : dateCompare; + } else { + return currentItemDate > dateCompare ? currentItemDate : dateCompare; + } + }, new Date(items[0][property])); + return desiredDate; +} + +function showHideGroup(event) { + const arrow = event.target; + const group = arrow.closest('.calendar-group'); + group.querySelector('.group-content').classList.toggle('inactive'); + group.querySelector('.group-expand').classList.toggle('inactive'); + group.querySelector('.group-collapse').classList.toggle('inactive'); + group.querySelector('.group-header').classList.toggle('content-hidden') +} + +function changePeriod(event) { + const arrow = event.target; + const direction = arrow.dataset.direction; + const wrapper = arrow.closest('.inc-dec-wrapper'); + const yearEl = wrapper.querySelector('.current-year'); + const contentWrapper = document.querySelector('.calendar-content-wrapper'); + const view = contentWrapper.dataset.view; + const currentYear = parseInt(yearEl.dataset.year); + const maxYear = viewEnd.getUTCFullYear(); // Use the maximum year from the viewEnd variable + const minYear = viewStart.getUTCFullYear(); // Use the minimum year from the viewStart variable + + let newPeriod, newYear, newQuarter; + + if (view === "quarter") { + const currentQuarter = parseInt(yearEl.dataset.quarter); + newQuarter = (direction == 'right') ? (currentQuarter + 1) : (currentQuarter - 1); + newYear = currentYear; + if (newQuarter > 4) { + newQuarter = 1; + newYear = currentYear + 1; + } + if (newQuarter < 1) { + newQuarter = 4; + newYear = currentYear - 1; + } + } else { + newYear = (direction == 'right') ? (currentYear + 1) : (currentYear - 1); + newQuarter = 1; + } + + // Prevent the year from going beyond the maximum or minimum year + if (newYear > maxYear || newYear < minYear) { + return; + } + + newPeriod = { 'year': newYear, 'quarter': newQuarter }; + refreshCalendar(newPeriod, view); +} + +function getUniqueYears(items) { + const yearsSet = new Set(); + items.forEach(item => { + const startDate = item[startDateProp]; + if (startDate) { + const year = startDate.split('-')[0]; + yearsSet.add(year); + } + }); + if (yearsSet.size === 0) { + const startYear = viewStart.getFullYear(); + const endYear = viewEnd.getFullYear(); + for (let year = startYear; year <= endYear; year++) { + yearsSet.add(year); + } + } + const years = Array.from(yearsSet); + years.sort((a, b) => parseInt(a) - parseInt(b)); + return years; +} + +// handle clicking on the year button +function toggleDropdown(element) { + const dropdown = element.closest('.filter-dropdown-wrapper'); + const iconChevronDown = dropdown.querySelector('.icon-chevronDown'); + const iconChevronUp = dropdown.querySelector('.icon-chevronUp'); + + iconChevronDown.classList.toggle('inactive'); + iconChevronUp.classList.toggle('inactive'); + dropdown.classList.toggle('active'); +} + +// handle clicks outside the dropdown +function dismissDropdown(event) { + const isInsideDropdown = event ? event.target.closest('.filter-dropdown-wrapper') : false; + if (!isInsideDropdown) { + const dropdown = document.querySelector('.filter-dropdown-wrapper'); + dropdown.querySelector('.icon-chevronDown').classList.remove('inactive'); + dropdown.querySelector('.icon-chevronUp').classList.add('inactive'); + dropdown.classList.remove('active'); + } +} + +function filterDropdownSelection(event, viewStartYear, numYears) { + // if we're hopping views, redraw the calendar + // if not just scroll + const calendarContentEl = document.querySelector('.calendar-content-wrapper'); + const currentView = calendarContentEl.dataset.view; + + const optionEl = event.target; + let year, quarter, view; + if (("period") in optionEl.dataset) { + // quarter view + quarter = optionEl.dataset.period; + year = document.querySelector('.inc-dec-wrapper .current-year').dataset.year; + view = "quarter"; + } else { + // year view + view = "year"; + year = optionEl.dataset.year; + quarter = 1; + } + + const period = { 'year': year, 'quarter': quarter } + + if (currentView === view) { + // scroll over + const scrollPct = calculateScroll(view, viewStartYear, year, quarter, numYears); + const calendarWrapper = document.querySelector('.calendar-wrapper'); + scrollToPosition(calendarWrapper, scrollPct); + } else { + const viewStr = view.charAt(0).toUpperCase() + view.slice(1); + document.querySelector('.filter-dropdown-button > .label').textContent = `Selected View: ${viewStr}`; + refreshCalendar(period, view); + } + dismissDropdown(); +} + +// retrieve the year via js when refreshing in quarter view +function refreshCalendar(period, view) { + const block = document.querySelector('.gmo-program-details.block'); + const yearEl = block.querySelector('.inc-dec-wrapper .current-year'); + yearEl.dataset.year = period.year; + yearEl.dataset.quarter = period.quarter; + + if (view === "year") { + yearEl.textContent = period.year; + } else { + yearEl.textContent = `Q${period.quarter} ${period.year}`; + } + + + // trick to remove event listeners + block.querySelector('.filter-dropdown-wrapper').outerHTML += ''; + block.querySelector('.right-controls .today-button').outerHTML += ''; + + block.querySelector('.calendar-wrapper').remove(); + block.querySelector('.filter-dropdown-content').remove(); + + buildCalendar(calendarDeliverables, block, view, deliverableMappings, period); +} + +function calendarYears(startYear, endYear) { + let years = []; + for (let year = startYear; year <= endYear; year++) { + years.push(year); + } + return years; +} + +function buildYearCal(years) { + const calendarEl = document.createElement('div'); + calendarEl.classList.add('calendar-wrapper'); + if (years.length > 1) calendarEl.classList.add('multiyear'); + const backgroundEl = document.createElement('div'); + backgroundEl.classList.add('calendar-background'); + backgroundEl.style.width = (years.length * 100) + '%'; + + years.forEach((year) => { + const yearWrapper = document.createElement('div'); + yearWrapper.dataset.year = year; + yearWrapper.classList.add('year-wrapper'); + yearWrapper.style.width = (100 / years.length) + '%'; + const calendarHeader = document.createElement('div'); + calendarHeader.classList.add('header-wrapper'); + const quartersHeader = document.createElement('div'); + quartersHeader.classList.add('quarter-header'); + quartersHeader.innerHTML = ` +
Q1 ${year}
+
Q2 ${year}
+
Q3 ${year}
+
Q4 ${year}
+ `; + calendarHeader.appendChild(quartersHeader); + + const monthsWrapper = document.createElement('div'); + monthsWrapper.classList.add('month-wrapper'); + monthsWrapper.dataset.year = year; + monthsWrapper.innerHTML = ` +
Jan
+
Feb
+
Mar
+
Apr
+
May
+
Jun
+
Jul
+
Aug
+
Sep
+
Oct
+
Nov
+
Dec
+ `; + yearWrapper.appendChild(calendarHeader); + yearWrapper.appendChild(monthsWrapper); + backgroundEl.appendChild(yearWrapper); + }); + calendarEl.appendChild(backgroundEl); + return calendarEl; +} + + +function buildQuarterCal(years) { + const calendarEl = document.createElement('div'); + calendarEl.classList.add('calendar-wrapper', 'quarter-view'); + if (years.length > 1) calendarEl.classList.add('multiyear'); + const backgroundEl = document.createElement('div'); + backgroundEl.classList.add('calendar-background'); + // this is wider for 'quarter' view- see equivalent in 'year' view. + backgroundEl.style.width = (years.length * 300) + '%'; + + years.forEach((year) => { + const yearWrapper = document.createElement('div'); + yearWrapper.dataset.year = year; + yearWrapper.classList.add('year-wrapper'); + yearWrapper.style.width = (100 / years.length) + '%'; + const calendarHeader = document.createElement('div'); + calendarHeader.classList.add('header-wrapper'); + const quartersHeader = document.createElement('div'); + quartersHeader.classList.add('quarter-header'); + quartersHeader.innerHTML = ` +
Fiscal Q1 ${year}
+
Fiscal Q2 ${year}
+
Fiscal Q3 ${year}
+
Fiscal Q4 ${year}
+ `; + calendarHeader.appendChild(quartersHeader); + + const monthsWrapper = document.createElement('div'); + monthsWrapper.classList.add('month-wrapper'); + monthsWrapper.dataset.year = year; + monthsWrapper.innerHTML = ` +
Dec Q1
+
Jan Q1
+
Feb Q1
+
Mar Q2
+
Apr Q2
+
May Q2
+
Jun Q3
+
Jul Q3
+
Aug Q3
+
Sep Q4
+
Oct Q4
+
Nov Q4
+ `; + yearWrapper.appendChild(calendarHeader); + yearWrapper.appendChild(monthsWrapper); + backgroundEl.appendChild(yearWrapper); + }); + calendarEl.appendChild(backgroundEl); + return calendarEl; +} + +function scrollOnInit(element, scrollPct) { + // Observer to detect when the element becomes visible + const observer = new IntersectionObserver((entries, observer) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + resizeGroups(); + scrollToPosition(element, scrollPct); + observer.disconnect(); // Stop observing after scrolling + } + }); + }); + + // Start observing the element + observer.observe(element); +} + +function getHorizontalOverflow(element) { + return element.scrollWidth - element.clientWidth; +} + +function resizeGroups() { + const groups = document.querySelectorAll('.calendar-group'); + groups.forEach((group) => { + const overflow = getHorizontalOverflow(group); + if (overflow) { + group.querySelector('.group-header').style.paddingRight = overflow + 'px'; + group.style.paddingRight = overflow + 'px'; + } + }) +} + +function scrollToPosition(element, scrollPct) { + const maxScrollLeft = element.scrollWidth; + const scrollAmt = (maxScrollLeft) * (scrollPct / 100); + element.scrollTo({ + left: scrollAmt, // Replace with desired position + behavior: 'smooth' // Optional: for smooth scrolling + }); +} + +function calculateScroll(type, viewStartYear, displayYear, displayQuarter, numYears) { + const yearDiff = displayYear - viewStartYear; + const yearWidthOffsetPct = (((yearDiff / numYears)) * 100); + + if (type === "quarter") { + return ((yearWidthOffsetPct) + ((displayQuarter - 1) * ((1 / numYears) / 4)) * 100).toFixed(2); + } else { + return (yearWidthOffsetPct).toFixed(2); + } +} + +function isValidDate(dateObj) { + return dateObj instanceof Date && !isNaN(dateObj); } diff --git a/blocks/gmo-program-list/gmo-program-list.js b/blocks/gmo-program-list/gmo-program-list.js index 927093f4..7a9bd3cf 100644 --- a/blocks/gmo-program-list/gmo-program-list.js +++ b/blocks/gmo-program-list/gmo-program-list.js @@ -181,6 +181,7 @@ async function buildCampaignList(campaigns, numPerPage) { const programName = campaign.node.programName; const campaignName = campaign.node.campaignName; const programID = campaign.node.programID ? campaign.node.programID : ""; + const path = campaign.node._path; campaignRow.classList.add('campaign-row'); if ((index + 1) > numPerPage) campaignRow.classList.add('hidden'); @@ -190,9 +191,9 @@ async function buildCampaignList(campaigns, numPerPage) { const campaignIconLink = document.createElement('a'); let campaignDetailsLink = host + `/${detailsPage}?programName=${programName}&`; - campaignDetailsLink += `programID=${programID}` + campaignDetailsLink += `programID=${programID}`; + campaignDetailsLink += `&path=${path}`; campaignIconLink.href = campaignDetailsLink; - const campaignIcon = document.createElement('div'); campaignIcon.classList.add('campaign-icon'); campaignIcon.dataset.programname = programName; @@ -203,7 +204,6 @@ async function buildCampaignList(campaigns, numPerPage) { const campaignNameWrapper = document.createElement('div'); campaignNameWrapper.classList.add('campaign-name-wrapper', 'vertical-center'); - campaignNameWrapper.innerHTML = `
${checkBlankString(programName)} diff --git a/icons/folderOpenOutline.svg b/icons/folderOpenOutline.svg new file mode 100644 index 00000000..ff67e983 --- /dev/null +++ b/icons/folderOpenOutline.svg @@ -0,0 +1,11 @@ + + + + + S FolderOpenOutline 18 N + + \ No newline at end of file diff --git a/icons/launch.svg b/icons/launch.svg new file mode 100644 index 00000000..e6209ba5 --- /dev/null +++ b/icons/launch.svg @@ -0,0 +1,13 @@ + + + + + S Launch 18 N + + + + \ No newline at end of file diff --git a/scripts/graphql.js b/scripts/graphql.js index f2e5ca5d..58012272 100644 --- a/scripts/graphql.js +++ b/scripts/graphql.js @@ -47,6 +47,8 @@ export async function graphqlAllCampaignsFilter(first,cursor,filter) { const encodedCursor = encodeURIComponent(cursor); const encodedFilter = encodeURIComponent(JSON.stringify(filter)); const graphqlEndpoint = `${baseApiUrl}/${projectId}/${queryName}${encodedSemiColon}first=${encodedFirst}${encodedSemiColon}cursor=${encodedCursor}${encodedSemiColon}filter=${encodedFilter}`; + //Performance logging + const startTime = performance.now(); const jwtToken = await getBearerToken(); try { @@ -57,6 +59,11 @@ export async function graphqlAllCampaignsFilter(first,cursor,filter) { }, }; const response = await fetch(`${graphqlEndpoint}`, options); + //Performance logging + const endTime = performance.now(); + const executionTime = endTime - startTime; + console.debug(`getAllCampaigns Execution Time: ${executionTime} ms`); + // Handle response codes if (response.status === 200) { const responseBody = await response.json(); @@ -170,6 +177,8 @@ export async function executeQuery(queryString) { const baseApiUrl = `${await getGraphqlEndpoint()}/graphql/execute.json`; const projectId = 'gmo'; const queryEndpoint = `${baseApiUrl}/${projectId}/${queryString}`; + //Performance logging + const startTime = performance.now(); const jwtToken = await getBearerToken(); return fetch(queryEndpoint, { @@ -181,6 +190,11 @@ export async function executeQuery(queryString) { if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } + //Performance logging + const endTime = performance.now(); + const executionTime = endTime - startTime; + console.debug(`executeQuery for ${queryString} Execution Time: ${executionTime} ms`); + return response.json(); }).then(data => { return data; // Make sure to return the data so that the promise resolves with it