From fa91f3acf815a686dee471490985fa67d1080f8c Mon Sep 17 00:00:00 2001 From: fkakatie Date: Fri, 10 Jul 2026 00:12:20 -0600 Subject: [PATCH 1/5] feat: xtract shared scheduling helpers for nav-promos reuse --- scripts/scripts.js | 530 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 486 insertions(+), 44 deletions(-) diff --git a/scripts/scripts.js b/scripts/scripts.js index 96a3c336..dd821890 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -573,6 +573,7 @@ function buildPDPBlock(main) { ['nav', `/${locale}/${language}/nav/nav`], ['footer', `/${locale}/${language}/footer/footer`], ['nav-banners', `/${locale}/${language}/nav/nav-banners`], + ['nav-promos', `/${locale}/${language}/nav/nav-promos`], ].forEach(([name, content]) => { const meta = document.createElement('meta'); meta.name = name; @@ -1159,6 +1160,38 @@ export function parseEasternDateTime(dateStr) { return new Date(Date.UTC(year, month - 1, day, utcHour, minutesValue, 0)); } +/** + * Parses the start/end date cells of a scheduled content row. + * Empty cells are treated as open-ended (always started / never expires). + * @param {HTMLElement} startEl - Cell containing the start datetime text + * @param {HTMLElement} endEl - Cell containing the end datetime text + * @returns {Object} `{ valid, start, end }`, or `{ valid: false, error, start: null, end: null }` + */ +function parseScheduleDates(startEl, endEl) { + const parseDateOrNull = (dateStr) => { + const trimmed = dateStr ? dateStr.trim() : ''; + if (!trimmed) { + return null; // Empty = open-ended + } + return parseEasternDateTime(trimmed); + }; + + try { + return { + valid: true, + start: parseDateOrNull(startEl.textContent), + end: parseDateOrNull(endEl.textContent), + }; + } catch (e) { + return { + valid: false, + error: e.message, + start: null, + end: null, + }; + } +} + /** * Parses alert banner rows from a block element and returns an array of banner objects. * Expected row format: @@ -1171,43 +1204,37 @@ export function parseEasternDateTime(dateStr) { * @returns {Array} Array of parsed banner objects with properties: */ export function parseAlertBanners(block) { - /** - * Parses a datetime string, returning null for empty values (open-ended). - * @param {string} dateStr - The datetime string to parse - * @returns {Date|null} The parsed Date or null if empty - */ - const parseDateOrNull = (dateStr) => { - const trimmed = dateStr?.trim(); - if (!trimmed) { - return null; // Empty = open-ended - } - return parseEasternDateTime(trimmed); - }; - const rows = [...block.children]; - const banners = rows.map((row) => { + return rows.map((row) => { const [startEl, endEl, content, colorEl] = [...row.children]; const color = colorEl.textContent.trim(); - try { - return ({ - valid: true, - start: parseDateOrNull(startEl.textContent), - end: parseDateOrNull(endEl.textContent), - content, - color, - }); - } catch (e) { - return { - valid: false, - error: e.message, - start: null, - end: null, - content, - color, - }; - } + return { + ...parseScheduleDates(startEl, endEl), + content, + color, + }; + }); +} + +/** + * Parses nav-promo rows from a block element and returns an array of promo objects. + * Expected row format: + * Column 1: Start datetime + * Column 2: End datetime + * Column 3: Content + * Defaults to Eastern time, same as parseAlertBanners. + * @param {HTMLElement} block - The DOM element containing nav-promo rows as children. + * @returns {Array} Array of parsed promo objects. + */ +export function parseNavPromos(block) { + const rows = [...block.children]; + return rows.map((row) => { + const [startEl, endEl, content] = [...row.children]; + return { + ...parseScheduleDates(startEl, endEl), + content, + }; }); - return banners; } /** @@ -1233,21 +1260,436 @@ export function currentPastFuture(start, end, date = new Date()) { } /** - * Finds the "best" alert banner from an array of banners, based on the current date. - * @param {Array} banners - Array of banner objects as returned by parseAlertBanners. + * Finds the "best" (currently active) item from an array of scheduled items. + * Works for any array of `{ valid, start, end }` objects, e.g. banners or nav-promos. + * @param {Array} items - Array of item objects from parseAlertBanners/parseNavPromos. * @param {Date} [date=new Date()] - The reference date/time to use (defaults to now). - * @returns {Object|null} The best banner object, or null if none are current. + * @returns {Object|null} The best item object, or null if none are current. */ -export function findBestAlertBanner(banners, date = new Date()) { - let bestBanner = null; - banners.forEach((banner) => { - if (banner.valid) { - if (currentPastFuture(banner.start, banner.end, date) === 'current') { - bestBanner = banner; +export function findCurrentScheduledItem(items, date = new Date()) { + let bestItem = null; + items.forEach((item) => { + if (item.valid) { + if (currentPastFuture(item.start, item.end, date) === 'current') { + bestItem = item; } } }); - return bestBanner; + return bestItem; +} + +/** + * Formats a Date object into a short date-time string format (M/D HHam/pm). + * @param {Date} date - The date to format + * @returns {string} Formatted date string, or "Invalid Date" or "Open" + */ +export function formatShortDateTime(date) { + if (date === null) { + return 'Open'; + } + if (!date || !(date instanceof Date) || Number.isNaN(date.getTime())) { + return 'Invalid Date'; + } + + const month = date.getMonth() + 1; // getMonth() returns 0-11 + const day = date.getDate(); + let hours = date.getHours(); + const minutes = date.getMinutes(); + + // Convert to 12-hour format + const ampm = hours >= 12 ? 'pm' : 'am'; + hours %= 12; + if (hours === 0) hours = 12; // 12am/12pm instead of 0am/0pm + + // Format time - only show minutes if not :00 + let timeStr = `${hours}${ampm}`; + if (minutes !== 0) { + const paddedMinutes = minutes.toString().padStart(2, '0'); + timeStr = `${hours}:${paddedMinutes}${ampm}`; + } + + return `${month}/${day} ${timeStr}`; +} + +/** + * Formats a duration in milliseconds into a human-readable string. + * Shows only the largest rounded unit (weeks for 21+ days, days for 1+ days, etc.) + * @param {number} ms - Duration in milliseconds + * @returns {string} Formatted duration string (e.g., "3d", "2w", "5h") + */ +export function formatDuration(ms) { + if (ms === null || Number.isNaN(ms)) return ''; + + const negative = ms < 0; + const absMs = Math.abs(ms); + + const totalMinutes = Math.round(absMs / (1000 * 60)); + const totalHours = totalMinutes / 60; + const totalDays = totalHours / 24; + const totalWeeks = totalDays / 7; + + let value; + let unit; + if (totalDays >= 21) { + // 3+ weeks: show weeks + value = Math.round(totalWeeks); + unit = value === 1 ? 'week' : 'weeks'; + } else if (totalDays >= 1) { + // 1+ days: show days + value = Math.round(totalDays); + unit = value === 1 ? 'day' : 'days'; + } else if (totalHours >= 1) { + // 1+ hours: show hours + value = Math.round(totalHours); + unit = value === 1 ? 'hour' : 'hours'; + } else { + // Less than 1 hour: show minutes + value = Math.round(totalMinutes); + unit = value === 1 ? 'minute' : 'minutes'; + } + + const result = `${value} ${unit}`; + return negative ? `-${result}` : result; +} + +/** + * Creates a timeline visualization showing scheduled items across 6 months. + * Shows 3 months before and 3 months after the current date. + * @param {Array} items - Array of scheduled item objects + * @param {Object|null} [bestItem=null] - The optimal item to highlight + * @param {Date} [date=new Date()] - Reference date for the "now" marker + * @param {Function} [onDateChange=null] - Callback when date is changed via drag (receives newDate) + * @param {Function} [onPreviewChange=null] - Callback when selected item changes + * @returns {HTMLElement} Timeline container element + */ +export function createTimeline( + items, + bestItem = null, + date = new Date(), + onDateChange = null, + onPreviewChange = null, +) { + const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const container = document.createElement('div'); + container.classList.add('alert-banners-timeline'); + + // Calculate 6-month window: 3 months before and 3 months after current date + const currentMonth = date.getMonth(); + const currentYear = date.getFullYear(); + + // Start 3 months before (beginning of that month) + let startMonth = currentMonth - 3; + let startYear = currentYear; + if (startMonth < 0) { + startMonth += 12; + startYear -= 1; + } + const rangeStart = new Date(startYear, startMonth, 1); + + // End 3 months after (end of that month) + let endMonth = currentMonth + 3; + let endYear = currentYear; + if (endMonth > 11) { + endMonth -= 12; + endYear += 1; + } + // Get last day of the end month + const rangeEnd = new Date(endYear, endMonth + 1, 0, 23, 59, 59); + + const rangeDuration = rangeEnd.getTime() - rangeStart.getTime(); + + // Build array of months to display + const displayMonths = []; + let m = startMonth; + let y = startYear; + for (let i = 0; i < 7; i += 1) { + displayMonths.push({ month: m, year: y, name: monthNames[m] }); + m += 1; + if (m > 11) { + m = 0; + y += 1; + } + } + + // Create header with months + const header = document.createElement('div'); + header.classList.add('timeline-header'); + + // Show year range or single year + const yearLabel = document.createElement('div'); + yearLabel.classList.add('timeline-year'); + if (startYear === endYear) { + yearLabel.textContent = startYear; + } else { + yearLabel.textContent = `${startYear}–${endYear}`; + } + header.appendChild(yearLabel); + + const monthsRow = document.createElement('div'); + monthsRow.classList.add('timeline-months'); + displayMonths.forEach(({ name }) => { + const monthDiv = document.createElement('div'); + monthDiv.classList.add('timeline-month'); + monthDiv.textContent = name; + monthsRow.appendChild(monthDiv); + }); + header.appendChild(monthsRow); + container.appendChild(header); + + // Create item rows + const rowsContainer = document.createElement('div'); + rowsContainer.classList.add('timeline-rows'); + + const visibleItems = []; // Track which items have visible bars + + items.forEach((item) => { + if (!item.valid) return; + + // Skip items with negative duration (end before start) + if (item.start && item.end && item.end < item.start) return; + + // Calculate bar position + const startTime = item.start ? item.start.getTime() : rangeStart.getTime(); + const endTime = item.end ? item.end.getTime() : rangeEnd.getTime(); + + // Clamp to visible range + const visibleStart = Math.max(startTime, rangeStart.getTime()); + const visibleEnd = Math.min(endTime, rangeEnd.getTime()); + + // Only show row if item overlaps with the visible range + if (visibleEnd < rangeStart.getTime() || visibleStart > rangeEnd.getTime()) return; + + const row = document.createElement('div'); + row.classList.add('timeline-row'); + + // Track for the bar + const track = document.createElement('div'); + track.classList.add('timeline-track'); + track.title = (item.content && item.content.textContent) ? item.content.textContent.trim() : ''; + + const leftPercent = ((visibleStart - rangeStart.getTime()) / rangeDuration) * 100; + const widthPercent = ((visibleEnd - visibleStart) / rangeDuration) * 100; + + const bar = document.createElement('div'); + bar.classList.add('timeline-bar'); + bar.classList.add(`timeline-bar-${currentPastFuture(item.start, item.end, date)}`); + + if (bestItem === item) { + bar.classList.add('timeline-bar-selected'); + } + + // Show arrows for open-ended dates + if (!item.start || item.start.getTime() < rangeStart.getTime()) { + bar.classList.add('timeline-bar-open-start'); + } + if (!item.end || item.end.getTime() > rangeEnd.getTime()) { + bar.classList.add('timeline-bar-open-end'); + } + + bar.style.left = `${Math.max(0, leftPercent)}%`; + bar.style.width = `${Math.min(100, widthPercent)}%`; + + // Tooltip + const startStr = item.start ? formatShortDateTime(item.start) : '∞'; + const endStr = item.end ? formatShortDateTime(item.end) : '∞'; + bar.title = `${startStr} → ${endStr}`; + + track.appendChild(bar); + visibleItems.push(item); // Track this item has a visible bar + + row.appendChild(track); + rowsContainer.appendChild(row); + }); + + container.appendChild(rowsContainer); + + // Add draggable "now" marker + const nowTime = date.getTime(); + if (nowTime >= rangeStart.getTime() && nowTime <= rangeEnd.getTime()) { + const nowPercent = ((nowTime - rangeStart.getTime()) / rangeDuration) * 100; + const nowMarker = document.createElement('div'); + nowMarker.classList.add('timeline-now'); + nowMarker.title = 'Drag to change date'; + + // Add date label (M/D format) + const dateLabel = document.createElement('div'); + dateLabel.classList.add('timeline-now-label'); + dateLabel.textContent = `${date.getMonth() + 1}/${date.getDate()}`; + nowMarker.appendChild(dateLabel); + + // Add drag handle + const handle = document.createElement('div'); + handle.classList.add('timeline-now-handle'); + nowMarker.appendChild(handle); + + // Position marker after container is in DOM + nowMarker.dataset.percent = nowPercent; + + // Drag functionality + if (onDateChange) { + let isDragging = false; + let trackBoundsCache = null; + + // Cache bars and track for performance + let barsCache = null; + let lastBestIdx = -1; + + const cacheTrackBounds = () => { + const track = container.querySelector('.timeline-track'); + if (track) { + trackBoundsCache = track.getBoundingClientRect(); + } else { + const months = container.querySelector('.timeline-months'); + trackBoundsCache = months ? months.getBoundingClientRect() : null; + } + // Also cache bars + barsCache = [...container.querySelectorAll('.timeline-bar')]; + }; + + // Update item bar colors and selection based on the new date + // Returns the current best item (for passing to onDateChange) + const updateBarColors = (newDate) => { + if (!barsCache) return null; + + let newBestIdx = -1; + + // Update bar classes + for (let i = 0; i < barsCache.length; i += 1) { + const bar = barsCache[i]; + const item = visibleItems[i]; + if (!item) continue; // eslint-disable-line no-continue + + const state = currentPastFuture(item.start, item.end, newDate); + + // Update state class (toggle instead of remove all + add) + bar.classList.toggle('timeline-bar-past', state === 'past'); + bar.classList.toggle('timeline-bar-current', state === 'current'); + bar.classList.toggle('timeline-bar-future', state === 'future'); + + if (state === 'current') { + newBestIdx = i; + } + } + + // Update selected class only if changed + if (newBestIdx !== lastBestIdx) { + if (lastBestIdx >= 0 && barsCache[lastBestIdx]) { + barsCache[lastBestIdx].classList.remove('timeline-bar-selected'); + } + if (newBestIdx >= 0 && barsCache[newBestIdx]) { + barsCache[newBestIdx].classList.add('timeline-bar-selected'); + } + lastBestIdx = newBestIdx; + + // Only notify preview change when selection changes + if (onPreviewChange) { + onPreviewChange(newBestIdx >= 0 ? visibleItems[newBestIdx] : null); + } + } + + return newBestIdx >= 0 ? visibleItems[newBestIdx] : null; + }; + + const updateMarkerFromX = (clientX) => { + if (!trackBoundsCache || trackBoundsCache.width === 0) return null; + + const relativeX = clientX - trackBoundsCache.left; + const percent = Math.max(0, Math.min(relativeX / trackBoundsCache.width, 1)); + + // Update marker position directly (don't re-render timeline) + const containerRect = container.getBoundingClientRect(); + const leftOffset = trackBoundsCache.left - containerRect.left; + const newLeft = leftOffset + (percent * trackBoundsCache.width); + nowMarker.style.left = `${newLeft}px`; + + // Calculate the new date + const newTime = rangeStart.getTime() + (percent * rangeDuration); + const newDate = new Date(newTime); + + // Update the date label + dateLabel.textContent = `${newDate.getMonth() + 1}/${newDate.getDate()}`; + + // Update bar colors and get current best item + const currentBest = updateBarColors(newDate); + + return { newDate, bestItem: currentBest }; + }; + + const onMouseMove = (e) => { + if (!isDragging) return; + e.preventDefault(); + const result = updateMarkerFromX(e.clientX); + if (result) { + onDateChange(result.newDate, result.bestItem); + } + }; + + const onMouseUp = () => { + isDragging = false; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + nowMarker.classList.remove('timeline-now-dragging'); + trackBoundsCache = null; + }; + + nowMarker.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + isDragging = true; + cacheTrackBounds(); // Cache bounds at start of drag + nowMarker.classList.add('timeline-now-dragging'); + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + + // Touch support + const onTouchMove = (e) => { + if (!isDragging) return; + e.preventDefault(); + const touch = e.touches[0]; + const result = updateMarkerFromX(touch.clientX); + if (result) { + onDateChange(result.newDate, result.bestItem); + } + }; + + const onTouchEnd = () => { + isDragging = false; + document.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('touchend', onTouchEnd); + nowMarker.classList.remove('timeline-now-dragging'); + trackBoundsCache = null; + }; + + nowMarker.addEventListener('touchstart', (e) => { + e.preventDefault(); + isDragging = true; + cacheTrackBounds(); + nowMarker.classList.add('timeline-now-dragging'); + document.addEventListener('touchmove', onTouchMove, { passive: false }); + document.addEventListener('touchend', onTouchEnd); + }); + + // Position marker after a frame so DOM is ready + requestAnimationFrame(() => { + const track = container.querySelector('.timeline-track'); + if (track) { + const trackRect = track.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const leftOffset = trackRect.left - containerRect.left; + const newLeft = leftOffset + (nowPercent / 100) * trackRect.width; + nowMarker.style.left = `${newLeft}px`; + } + }); + } else { + // Non-interactive: use CSS calc + nowMarker.style.left = `${nowPercent}%`; + } + + container.appendChild(nowMarker); + } + + return container; } /** @@ -1267,7 +1709,7 @@ async function loadNavBanner(main) { const block = dom.querySelector('.alert-banners'); const banners = parseAlertBanners(block); - const selectedBanner = findBestAlertBanner(banners); + const selectedBanner = findCurrentScheduledItem(banners); if (selectedBanner && selectedBanner.content) { const banner = document.createElement('aside'); From d2f0b2698df7b47f6040e74f8f04e5bee6fe9538 Mon Sep 17 00:00:00 2001 From: fkakatie Date: Fri, 10 Jul 2026 00:12:49 -0600 Subject: [PATCH 2/5] fix: import scheduling helpers from scripts.js --- blocks/alert-banners/alert-banners.js | 422 +------------------------- 1 file changed, 5 insertions(+), 417 deletions(-) diff --git a/blocks/alert-banners/alert-banners.js b/blocks/alert-banners/alert-banners.js index 76821557..c8963606 100644 --- a/blocks/alert-banners/alert-banners.js +++ b/blocks/alert-banners/alert-banners.js @@ -1,424 +1,12 @@ import { parseAlertBanners, - findBestAlertBanner, + findCurrentScheduledItem, currentPastFuture, + formatShortDateTime, + formatDuration, + createTimeline, } from '../../scripts/scripts.js'; -const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - -/** - * Formats a Date object into a short date-time string format (M/D HHam/pm). - * @param {Date} date - The date to format - * @returns {string} Formatted date string, or "Invalid Date" or "Open" - */ -function formatShortDateTime(date) { - if (date === null) { - return 'Open'; - } - if (!date || !(date instanceof Date) || Number.isNaN(date.getTime())) { - return 'Invalid Date'; - } - - const month = date.getMonth() + 1; // getMonth() returns 0-11 - const day = date.getDate(); - let hours = date.getHours(); - const minutes = date.getMinutes(); - - // Convert to 12-hour format - const ampm = hours >= 12 ? 'pm' : 'am'; - hours %= 12; - if (hours === 0) hours = 12; // 12am/12pm instead of 0am/0pm - - // Format time - only show minutes if not :00 - let timeStr = `${hours}${ampm}`; - if (minutes !== 0) { - const paddedMinutes = minutes.toString().padStart(2, '0'); - timeStr = `${hours}:${paddedMinutes}${ampm}`; - } - - return `${month}/${day} ${timeStr}`; -} - -/** - * Formats a duration in milliseconds into a human-readable string. - * Shows only the largest rounded unit (weeks for 21+ days, days for 1+ days, etc.) - * @param {number} ms - Duration in milliseconds - * @returns {string} Formatted duration string (e.g., "3d", "2w", "5h") - */ -function formatDuration(ms) { - if (ms === null || Number.isNaN(ms)) return ''; - - const negative = ms < 0; - const absMs = Math.abs(ms); - - const totalMinutes = Math.round(absMs / (1000 * 60)); - const totalHours = totalMinutes / 60; - const totalDays = totalHours / 24; - const totalWeeks = totalDays / 7; - - let value; - let unit; - if (totalDays >= 21) { - // 3+ weeks: show weeks - value = Math.round(totalWeeks); - unit = value === 1 ? 'week' : 'weeks'; - } else if (totalDays >= 1) { - // 1+ days: show days - value = Math.round(totalDays); - unit = value === 1 ? 'day' : 'days'; - } else if (totalHours >= 1) { - // 1+ hours: show hours - value = Math.round(totalHours); - unit = value === 1 ? 'hour' : 'hours'; - } else { - // Less than 1 hour: show minutes - value = Math.round(totalMinutes); - unit = value === 1 ? 'minute' : 'minutes'; - } - - const result = `${value} ${unit}`; - return negative ? `-${result}` : result; -} - -/** - * Creates a timeline visualization showing banners across 6 months. - * Shows 3 months before and 3 months after the current date. - * @param {Array} banners - Array of banner objects - * @param {Object|null} [bestBanner=null] - The optimal banner to highlight - * @param {Date} [date=new Date()] - Reference date for the "now" marker - * @param {Function} [onDateChange=null] - Callback when date is changed via drag (receives newDate) - * @param {Function} [onPreviewChange=null] - Callback when selected banner changes - * @returns {HTMLElement} Timeline container element - */ -function createTimeline( - banners, - bestBanner = null, - date = new Date(), - onDateChange = null, - onPreviewChange = null, -) { - const container = document.createElement('div'); - container.classList.add('alert-banners-timeline'); - - // Calculate 6-month window: 3 months before and 3 months after current date - const currentMonth = date.getMonth(); - const currentYear = date.getFullYear(); - - // Start 3 months before (beginning of that month) - let startMonth = currentMonth - 3; - let startYear = currentYear; - if (startMonth < 0) { - startMonth += 12; - startYear -= 1; - } - const rangeStart = new Date(startYear, startMonth, 1); - - // End 3 months after (end of that month) - let endMonth = currentMonth + 3; - let endYear = currentYear; - if (endMonth > 11) { - endMonth -= 12; - endYear += 1; - } - // Get last day of the end month - const rangeEnd = new Date(endYear, endMonth + 1, 0, 23, 59, 59); - - const rangeDuration = rangeEnd.getTime() - rangeStart.getTime(); - - // Build array of months to display - const displayMonths = []; - let m = startMonth; - let y = startYear; - for (let i = 0; i < 7; i += 1) { - displayMonths.push({ month: m, year: y, name: MONTH_NAMES[m] }); - m += 1; - if (m > 11) { - m = 0; - y += 1; - } - } - - // Create header with months - const header = document.createElement('div'); - header.classList.add('timeline-header'); - - // Show year range or single year - const yearLabel = document.createElement('div'); - yearLabel.classList.add('timeline-year'); - if (startYear === endYear) { - yearLabel.textContent = startYear; - } else { - yearLabel.textContent = `${startYear}–${endYear}`; - } - header.appendChild(yearLabel); - - const monthsRow = document.createElement('div'); - monthsRow.classList.add('timeline-months'); - displayMonths.forEach(({ name }) => { - const monthDiv = document.createElement('div'); - monthDiv.classList.add('timeline-month'); - monthDiv.textContent = name; - monthsRow.appendChild(monthDiv); - }); - header.appendChild(monthsRow); - container.appendChild(header); - - // Create banner rows - const rowsContainer = document.createElement('div'); - rowsContainer.classList.add('timeline-rows'); - - const visibleBanners = []; // Track which banners have visible bars - - banners.forEach((banner) => { - if (!banner.valid) return; - - // Skip banners with negative duration (end before start) - if (banner.start && banner.end && banner.end < banner.start) return; - - // Calculate bar position - const startTime = banner.start ? banner.start.getTime() : rangeStart.getTime(); - const endTime = banner.end ? banner.end.getTime() : rangeEnd.getTime(); - - // Clamp to visible range - const visibleStart = Math.max(startTime, rangeStart.getTime()); - const visibleEnd = Math.min(endTime, rangeEnd.getTime()); - - // Only show row if banner overlaps with the visible range - if (visibleEnd < rangeStart.getTime() || visibleStart > rangeEnd.getTime()) return; - - const row = document.createElement('div'); - row.classList.add('timeline-row'); - - // Track for the bar - const track = document.createElement('div'); - track.classList.add('timeline-track'); - track.title = banner.content?.textContent?.trim() || ''; - - const leftPercent = ((visibleStart - rangeStart.getTime()) / rangeDuration) * 100; - const widthPercent = ((visibleEnd - visibleStart) / rangeDuration) * 100; - - const bar = document.createElement('div'); - bar.classList.add('timeline-bar'); - bar.classList.add(`timeline-bar-${currentPastFuture(banner.start, banner.end, date)}`); - - if (bestBanner === banner) { - bar.classList.add('timeline-bar-selected'); - } - - // Show arrows for open-ended dates - if (!banner.start || banner.start.getTime() < rangeStart.getTime()) { - bar.classList.add('timeline-bar-open-start'); - } - if (!banner.end || banner.end.getTime() > rangeEnd.getTime()) { - bar.classList.add('timeline-bar-open-end'); - } - - bar.style.left = `${Math.max(0, leftPercent)}%`; - bar.style.width = `${Math.min(100, widthPercent)}%`; - - // Tooltip - const startStr = banner.start ? formatShortDateTime(banner.start) : '∞'; - const endStr = banner.end ? formatShortDateTime(banner.end) : '∞'; - bar.title = `${startStr} → ${endStr}`; - - track.appendChild(bar); - visibleBanners.push(banner); // Track this banner has a visible bar - - row.appendChild(track); - rowsContainer.appendChild(row); - }); - - container.appendChild(rowsContainer); - - // Add draggable "now" marker - const nowTime = date.getTime(); - if (nowTime >= rangeStart.getTime() && nowTime <= rangeEnd.getTime()) { - const nowPercent = ((nowTime - rangeStart.getTime()) / rangeDuration) * 100; - const nowMarker = document.createElement('div'); - nowMarker.classList.add('timeline-now'); - nowMarker.title = 'Drag to change date'; - - // Add date label (M/D format) - const dateLabel = document.createElement('div'); - dateLabel.classList.add('timeline-now-label'); - dateLabel.textContent = `${date.getMonth() + 1}/${date.getDate()}`; - nowMarker.appendChild(dateLabel); - - // Add drag handle - const handle = document.createElement('div'); - handle.classList.add('timeline-now-handle'); - nowMarker.appendChild(handle); - - // Position marker after container is in DOM - nowMarker.dataset.percent = nowPercent; - - // Drag functionality - if (onDateChange) { - let isDragging = false; - let trackBoundsCache = null; - - // Cache bars and track for performance - let barsCache = null; - let lastBestIdx = -1; - - const cacheTrackBounds = () => { - const track = container.querySelector('.timeline-track'); - if (track) { - trackBoundsCache = track.getBoundingClientRect(); - } else { - const months = container.querySelector('.timeline-months'); - trackBoundsCache = months ? months.getBoundingClientRect() : null; - } - // Also cache bars - barsCache = [...container.querySelectorAll('.timeline-bar')]; - }; - - // Update banner bar colors and selection based on the new date - // Returns the current best banner (for passing to onDateChange) - const updateBarColors = (newDate) => { - if (!barsCache) return null; - - let newBestIdx = -1; - - // Update bar classes - for (let i = 0; i < barsCache.length; i += 1) { - const bar = barsCache[i]; - const banner = visibleBanners[i]; - if (!banner) continue; // eslint-disable-line no-continue - - const state = currentPastFuture(banner.start, banner.end, newDate); - - // Update state class (toggle instead of remove all + add) - bar.classList.toggle('timeline-bar-past', state === 'past'); - bar.classList.toggle('timeline-bar-current', state === 'current'); - bar.classList.toggle('timeline-bar-future', state === 'future'); - - if (state === 'current') { - newBestIdx = i; - } - } - - // Update selected class only if changed - if (newBestIdx !== lastBestIdx) { - if (lastBestIdx >= 0 && barsCache[lastBestIdx]) { - barsCache[lastBestIdx].classList.remove('timeline-bar-selected'); - } - if (newBestIdx >= 0 && barsCache[newBestIdx]) { - barsCache[newBestIdx].classList.add('timeline-bar-selected'); - } - lastBestIdx = newBestIdx; - - // Only notify preview change when selection changes - if (onPreviewChange) { - onPreviewChange(newBestIdx >= 0 ? visibleBanners[newBestIdx] : null); - } - } - - return newBestIdx >= 0 ? visibleBanners[newBestIdx] : null; - }; - - const updateMarkerFromX = (clientX) => { - if (!trackBoundsCache || trackBoundsCache.width === 0) return null; - - const relativeX = clientX - trackBoundsCache.left; - const percent = Math.max(0, Math.min(relativeX / trackBoundsCache.width, 1)); - - // Update marker position directly (don't re-render timeline) - const containerRect = container.getBoundingClientRect(); - const leftOffset = trackBoundsCache.left - containerRect.left; - const newLeft = leftOffset + (percent * trackBoundsCache.width); - nowMarker.style.left = `${newLeft}px`; - - // Calculate the new date - const newTime = rangeStart.getTime() + (percent * rangeDuration); - const newDate = new Date(newTime); - - // Update the date label - dateLabel.textContent = `${newDate.getMonth() + 1}/${newDate.getDate()}`; - - // Update bar colors and get current best banner - const currentBest = updateBarColors(newDate); - - return { newDate, bestBanner: currentBest }; - }; - - const onMouseMove = (e) => { - if (!isDragging) return; - e.preventDefault(); - const result = updateMarkerFromX(e.clientX); - if (result) { - onDateChange(result.newDate, result.bestBanner); - } - }; - - const onMouseUp = () => { - isDragging = false; - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - nowMarker.classList.remove('timeline-now-dragging'); - trackBoundsCache = null; - }; - - nowMarker.addEventListener('mousedown', (e) => { - e.preventDefault(); - e.stopPropagation(); - isDragging = true; - cacheTrackBounds(); // Cache bounds at start of drag - nowMarker.classList.add('timeline-now-dragging'); - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }); - - // Touch support - const onTouchMove = (e) => { - if (!isDragging) return; - e.preventDefault(); - const touch = e.touches[0]; - const result = updateMarkerFromX(touch.clientX); - if (result) { - onDateChange(result.newDate, result.bestBanner); - } - }; - - const onTouchEnd = () => { - isDragging = false; - document.removeEventListener('touchmove', onTouchMove); - document.removeEventListener('touchend', onTouchEnd); - nowMarker.classList.remove('timeline-now-dragging'); - trackBoundsCache = null; - }; - - nowMarker.addEventListener('touchstart', (e) => { - e.preventDefault(); - isDragging = true; - cacheTrackBounds(); - nowMarker.classList.add('timeline-now-dragging'); - document.addEventListener('touchmove', onTouchMove, { passive: false }); - document.addEventListener('touchend', onTouchEnd); - }); - - // Position marker after a frame so DOM is ready - requestAnimationFrame(() => { - const track = container.querySelector('.timeline-track'); - if (track) { - const trackRect = track.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - const leftOffset = trackRect.left - containerRect.left; - const newLeft = leftOffset + (nowPercent / 100) * trackRect.width; - nowMarker.style.left = `${newLeft}px`; - } - }); - } else { - // Non-interactive: use CSS calc - nowMarker.style.left = `${nowPercent}%`; - } - - container.appendChild(nowMarker); - } - - return container; -} - /** * Creates a structured list of parsed banner elements with appropriate CSS classes and content. * @param {Array} banners - Array of banner objects @@ -540,7 +128,7 @@ export default async function decorateAlertBanners(block) { // Full update function (used on initial load and when input changes) const updateViews = (simDate) => { - const simBestBanner = findBestAlertBanner(banners, simDate); + const simBestBanner = findCurrentScheduledItem(banners, simDate); // Update datetime input dtl.value = simDate.toISOString().slice(0, 16); From 617ae31f954d20105e54d582e640969bb4c9b394 Mon Sep 17 00:00:00 2001 From: fkakatie Date: Fri, 10 Jul 2026 00:13:13 -0600 Subject: [PATCH 3/5] feat: add nav-promos block for scheduled promo content --- blocks/nav-promos/nav-promos.css | 11 +++ blocks/nav-promos/nav-promos.js | 156 +++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 blocks/nav-promos/nav-promos.css create mode 100644 blocks/nav-promos/nav-promos.js diff --git a/blocks/nav-promos/nav-promos.css b/blocks/nav-promos/nav-promos.css new file mode 100644 index 00000000..e786b85b --- /dev/null +++ b/blocks/nav-promos/nav-promos.css @@ -0,0 +1,11 @@ +@import url('../alert-banners/alert-banners.css'); + +main > .section > div.nav-promos-wrapper { + padding: 0; +} + +.nav-promos-preview { + list-style: none; + margin: 0 0 16px; + padding: 0; +} diff --git a/blocks/nav-promos/nav-promos.js b/blocks/nav-promos/nav-promos.js new file mode 100644 index 00000000..10fa5b15 --- /dev/null +++ b/blocks/nav-promos/nav-promos.js @@ -0,0 +1,156 @@ +import { + parseNavPromos, + findCurrentScheduledItem, + currentPastFuture, + formatShortDateTime, + formatDuration, + createTimeline, +} from '../../scripts/scripts.js'; +import { buildPromoCard } from '../header/header.js'; + +/** + * Creates a structured list of parsed promo elements with appropriate CSS classes and content. + * @param {Array} promos - Array of promo objects + * @param {Object|null} [bestPromo=null] - The optimal promo to highlight with special styling + * @param {Date} [date=new Date()] - Reference date for determining promo status + * @returns {HTMLUListElement} Unordered list element containing all promo items + */ +function createParsedPromos(promos, bestPromo = null, date = new Date()) { + const list = document.createElement('ul'); + promos.forEach((promo) => { + const row = document.createElement('li'); + if (bestPromo === promo) { + row.classList.add('alert-banners-selected'); + } + + const duration = promo.end - promo.start; + const isNegativeDuration = duration < 0; + + if (promo.valid && !isNegativeDuration) { + row.classList.add('alert-banners-valid'); + } else { + row.classList.add('alert-banners-invalid'); + } + row.classList.add(`alert-banners-${currentPastFuture(promo.start, promo.end, date)}`); + + list.appendChild(row); + row.innerHTML = ` +
${formatShortDateTime(promo.start)} - ${formatShortDateTime(promo.end)} [${formatDuration(duration)}]
+
${promo.content.innerHTML}
+ `; + }); + return list; +} + +/** + * Renders the author-facing preview: timeline, date simulator, list, and a live preview card + * built from the same buildPromoCard used in production, so authors see the actual markup. + * @param {HTMLElement} block - The nav-promos block element + * @param {Array} promos - Parsed promo rows + */ +function renderPreview(block, promos) { + const previewWrapper = document.createElement('div'); + previewWrapper.className = 'nav-promos-preview'; + + const timelineContainer = document.createElement('div'); + timelineContainer.classList.add('alert-banners-timeline-container'); + + const promosContainer = document.createElement('div'); + + const div = document.createElement('div'); + div.classList.add('alert-banners-datetime'); + div.textContent = 'Simulate Date/Time (local)'; + const dtl = document.createElement('input'); + dtl.type = 'datetime-local'; + dtl.value = new Date().toISOString().slice(0, 16); + dtl.id = 'nav-promos-party-time'; + div.append(dtl); + + // Track current preview promo to avoid unnecessary DOM updates + let currentPreviewPromo = null; + + // Helper to update the preview card (skips if the promo hasn't changed) + const setPreview = (selectedPromo) => { + if (selectedPromo === currentPreviewPromo) return; + currentPreviewPromo = selectedPromo; + + previewWrapper.textContent = ''; + + if (selectedPromo && selectedPromo.content) { + previewWrapper.append(buildPromoCard(selectedPromo.content.cloneNode(true))); + } else { + const empty = document.createElement('div'); + empty.className = 'alert-banners-preview-empty'; + empty.textContent = 'No promo selected for this date'; + previewWrapper.append(empty); + } + }; + + // Cache list items for fast updates during drag + let listItems = []; + + const updateListColors = (newDate, newBestPromo) => { + listItems.forEach((item, index) => { + const promo = promos[index]; + if (!promo) return; + + const state = currentPastFuture(promo.start, promo.end, newDate); + item.classList.toggle('alert-banners-past', state === 'past'); + item.classList.toggle('alert-banners-current', state === 'current'); + item.classList.toggle('alert-banners-future', state === 'future'); + item.classList.toggle('alert-banners-selected', promo === newBestPromo); + }); + }; + + const onDrag = (newDate, newBestPromo) => { + dtl.value = newDate.toISOString().slice(0, 16); + updateListColors(newDate, newBestPromo); + }; + + // Full update function (used on initial load and when input changes) + const updateViews = (simDate) => { + const simBestPromo = findCurrentScheduledItem(promos, simDate); + + dtl.value = simDate.toISOString().slice(0, 16); + + setPreview(simBestPromo); + + timelineContainer.textContent = ''; + timelineContainer.append(createTimeline(promos, simBestPromo, simDate, onDrag, setPreview)); + + promosContainer.textContent = ''; + promosContainer.append(createParsedPromos(promos, simBestPromo, simDate)); + listItems = [...promosContainer.querySelectorAll('li')]; + }; + + updateViews(new Date()); + + dtl.addEventListener('input', (e) => { + updateViews(new Date(e.target.value)); + }); + + block.append(previewWrapper); + block.append(timelineContainer); + block.append(promosContainer); + block.append(div); +} + +/** + * Decorates the nav-promos block: parses schedule rows and either replaces the block with + * just the currently-active promo's content (when fetched programmatically by the header's + * Products mega-menu, which happens inside a detached document) or renders the full + * author-preview UI (when viewed directly as a page). + * @param {Element} block The nav-promos block element + */ +export default function decorate(block) { + const promos = parseNavPromos(block); + + if (!document.contains(block)) { + const best = findCurrentScheduledItem(promos); + block.replaceChildren(...(best ? best.content.childNodes : [])); + return; + } + + block.innerHTML = ''; + renderPreview(block, promos); +} From d6c34f9ba00e2bd6a81c20fe8c721f86f1f01351 Mon Sep 17 00:00:00 2001 From: fkakatie Date: Fri, 10 Jul 2026 00:13:50 -0600 Subject: [PATCH 4/5] refactor: redesign mega-menu and preserve legacy support --- blocks/header/header.css | 226 +++++++++++++++++++++++++++++++++--- blocks/header/header.js | 240 +++++++++++++++++++++++++++++++-------- 2 files changed, 401 insertions(+), 65 deletions(-) diff --git a/blocks/header/header.css b/blocks/header/header.css index 1031d9b7..8e76e07c 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -73,7 +73,7 @@ header section[data-expanded='true'] { } header [aria-hidden='true'] { - display: none; + display: none !important; } @media (width >= 1000px) { @@ -124,12 +124,13 @@ header .nav-sections { overflow-y: auto; } -header .nav-sections [data-source="fragment"] > a, -header .nav-sections [data-source="fragment"] > button { +header .nav-sections .submenu-wrapper[data-source="fragment"] > a, +header .nav-sections .submenu-wrapper[data-source="fragment"] > button { display: none; } -header .nav-sections ul > li a { +header .nav-sections nav > ul > li > a, +header .nav-sections .submenu-wrapper ul > li a { display: block; padding: var(--spacing-100) 0; line-height: 1.625em; @@ -154,11 +155,6 @@ header .nav-sections .submenu-wrapper ul > li { padding: 0 var(--spacing-200); } -header .nav-sections nav > ul > li:not([class]) { - border-top: 1px solid var(--color-gray-700); - font-weight: 500; -} - header .nav-sections .submenu-wrapper ul > li { border-top: 1px dashed var(--color-gray-400); } @@ -246,8 +242,8 @@ header .nav-sections .submenu-wrapper ul > li.submenu-wrapper ul > li a:last-chi padding: var(--spacing-80) 0; } - header .nav-sections [data-source="fragment"] > a, - header .nav-sections [data-source="fragment"] > button { + header .nav-sections .submenu-wrapper[data-source="fragment"] > a, + header .nav-sections .submenu-wrapper[data-source="fragment"] > button { display: unset; } @@ -261,12 +257,12 @@ header .nav-sections .submenu-wrapper ul > li.submenu-wrapper ul > li a:last-chi flex-direction: row-reverse; gap: var(--spacing-20); } - + header .nav-sections .submenu-wrapper ul > li:not([class]) { padding: 0 var(--spacing-80); } - header .nav-sections nav > ul > li.submenu-wrapper > [aria-expanded="true"] + ul { + header .nav-sections nav > ul > li.submenu-wrapper > [aria-expanded="true"] + * { display: grid; grid-template-columns: repeat(3, minmax(0, 2fr)) 1fr; gap: 0 var(--spacing-100); @@ -286,11 +282,6 @@ header .nav-sections .submenu-wrapper ul > li.submenu-wrapper ul > li a:last-chi display: block; } - header.header-commercial .nav-sections nav > ul > li.submenu-wrapper > [aria-expanded="true"] + ul { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - - header .nav-sections nav > ul > li.submenu-wrapper > ul > li.submenu-wrapper { grid-row: 1 / span 9; padding: 0; @@ -331,6 +322,193 @@ header .nav-sections .submenu-wrapper ul > li.submenu-wrapper ul > li a:last-chi } } +/* legacy submenu ends above, new mega-menu starts below */ +header .nav-sections .nav-mega { + display: flex; + flex-direction: column; +} + +header .nav-sections .nav-category { + display: grid; + grid-template-columns: 1fr 48px; + align-items: center; +} + +header .nav-sections ul > li.nav-category { + border-top: 1px solid var(--color-gray-400); +} + +header .nav-sections .nav-categories > .nav-category:last-child { + border-bottom: 1px solid var(--color-gray-400); +} + +header .nav-sections .nav-category ul > li { + border: none; + padding: 0; +} + +header .nav-sections .nav-category ul > li a { + display: block; + padding: 0.5em 0; +} + +header .nav-sections .nav-category a:hover, +header .nav-sections .nav-category a:focus-visible { + text-decoration: underline; +} + +header .nav-sections .nav-category p { + grid-column: 1; + margin: 0; + padding: var(--spacing-100) 0; + font-weight: bold; + letter-spacing: var(--letter-spacing-s); + text-transform: uppercase; + cursor: pointer; +} + +header .nav-sections .nav-category button.submenu-toggle { + grid-column: 2; + cursor: pointer; +} + +header .nav-sections .nav-category button[aria-expanded="true"] i.symbol-chevron { + transform: rotate(225deg); +} + +header .nav-sections .nav-category > ul { + grid-column: 1 / span 2; +} + +header .nav-sections .nav-category ul > li > strong > a { + font-weight: bold; + text-decoration: underline; + text-underline-offset: var(--spacing-20); +} + +header .nav-sections .nav-category ul > li > strong > a::after { + content: ''; + display: inline-block; + width: 6px; + height: 6px; + margin-left: var(--spacing-60); + border: 1.5px solid transparent; + border-top-color: currentcolor; + border-right-color: currentcolor; + border-radius: var(--rounding-s); + transform: rotate(45deg); +} + +header .nav-sections .nav-category ul > li em { + display: inline-block; + margin-left: 1ch; + border-radius: var(--rounding-s); + padding: 0 0.75em; + background-color: var(--color-charcoal); + color: var(--color-white); + font-size: var(--detail-size-s); + font-style: normal; + font-weight: bold; + text-transform: uppercase; +} + +@media (width >= 1000px) { + header .nav-sections nav > ul > li.submenu-wrapper > [aria-expanded="true"] + div.nav-mega { + display: grid; + grid-template-columns: 4fr 1fr; + gap: var(--spacing-xl); + } + + header .nav-sections .nav-categories { + column-count: 4; + column-gap: var(--spacing-xl); + } + + header .nav-sections ul > li.nav-category { + display: block; + margin-top: var(--spacing-200); + border-top: 0; + padding: 0; + break-inside: avoid; + } + + header .nav-sections .nav-categories .nav-category ul > li { + padding: 0; + } + + header .nav-sections .nav-category:first-child { + margin-top: 0; + } + + header .nav-sections .nav-categories > .nav-category:last-child { + border-bottom: 0; + } + + header .nav-sections .nav-category button.submenu-toggle { + display: none; + } + + header .nav-sections .nav-category p { + padding-top: 0; + border-bottom: 1px solid var(--color-gray-400); + cursor: default; + } + + header .nav-sections .nav-category > ul { + padding: var(--spacing-100) 0 0; + } +} + +/* promo */ +header .nav-sections .nav-promo { + padding: var(--spacing-200); +} + +@media (width < 1000px) { + header .nav-sections .nav-promo { + order: -1; + } +} + +header .nav-sections .nav-promo h2 { + font-family: var(--body-font-family); + font-size: var(--heading-size-l); + font-weight: bold; +} + +header .nav-sections .nav-promo img { + aspect-ratio: 16 / 9; + border-radius: var(--rounding-m); + object-fit: cover; +} + +header .nav-sections .nav-promo .button-wrapper .button { + width: 100%; +} + +header .nav-sections .nav-promo .button-wrapper .button::after { + content: ''; + display: inline-block; + width: 6px; + height: 6px; + margin-left: var(--spacing-60); + border: 1.5px solid transparent; + border-top-color: currentcolor; + border-right-color: currentcolor; + border-radius: var(--rounding-s); + transform: rotate(45deg); +} + +@media (width >= 1000px) { + header .nav-sections .nav-promo { + padding: 0; + } + + header .nav-sections .nav-promo img { + aspect-ratio: unset; + } +} + /* tools */ header .nav-tools { grid-area: tools; @@ -696,9 +874,21 @@ header.header-commercial .nav-sections nav > ul > li.submenu-wrapper > [aria-exp background-color: var(--color-dark-charcoal); border-color: var(--color-gray-700); color: var(--color-white); + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +header.header-commercial .nav-sections nav > ul > li.submenu-wrapper > [aria-expanded="true"] + div.nav-mega { + background-color: var(--color-dark-charcoal); + border-color: var(--color-gray-700); + color: var(--color-white); } header.header-commercial .nav-sections .submenu-wrapper ul > li.submenu-wrapper ul > li a:last-child { border-color: var(--color-gray-700); color: inherit; } + +header.header-commercial .nav-sections .nav-category, +header.header-commercial .nav-sections .nav-category p { + border-color: var(--color-gray-700); +} diff --git a/blocks/header/header.js b/blocks/header/header.js index d624d017..8398620f 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -118,6 +118,36 @@ function buildLanguageSelector(tool) { }); } +/** + * Builds a submenu toggle button for a given label and submenu list, wiring up its aria state. + * @param {string} labelText - Text used to generate the submenu id and aria-label + * @param {HTMLElement} submenu - The