diff --git a/tools/oversight/charts/skyline.js b/tools/oversight/charts/skyline.js index 713ed657..d8263610 100644 --- a/tools/oversight/charts/skyline.js +++ b/tools/oversight/charts/skyline.js @@ -42,16 +42,20 @@ export default class SkylineChart extends AbstractChart { groupFn.fillerFn = (existing) => { const endDate = this.chartConfig.endDate ? new Date(this.chartConfig.endDate) : new Date(); - // set start date depending on the unit - let startDate = new Date(endDate); - // roll back to beginning of time - if (this.chartConfig.unit === 'day') startDate.setDate(endDate.getDate() - 30); - if (this.chartConfig.unit === 'hour') startDate.setDate(endDate.getDate() - 7); - if (this.chartConfig.unit === 'week') startDate.setMonth(endDate.getMonth() - 12); - if (this.chartConfig.startDate) { - // nevermind, we have a start date in the config, let's use that + + let startDate; + if (!this.chartConfig.startDate) { + // set start date depending on the unit + startDate = new Date(endDate); + // roll back to beginning of time + if (this.chartConfig.unit === 'day') startDate.setDate(endDate.getDate() - 30); + if (this.chartConfig.unit === 'hour') startDate.setDate(endDate.getDate() - 7); + if (this.chartConfig.unit === 'week') startDate.setMonth(endDate.getMonth() - 12); + if (this.chartConfig.unit === 'month') startDate.setMonth(endDate.getMonth() - 1); + } else { startDate = new Date(this.chartConfig.startDate); } + const slots = new Set(existing); const slotTime = new Date(startDate); // return Array.from(slots); @@ -62,6 +66,7 @@ export default class SkylineChart extends AbstractChart { if (this.chartConfig.unit === 'day') slotTime.setDate(slotTime.getDate() + 1); if (this.chartConfig.unit === 'hour') slotTime.setHours(slotTime.getHours() + 1); if (this.chartConfig.unit === 'week') slotTime.setDate(slotTime.getDate() + 7); + if (this.chartConfig.unit === 'month') slotTime.setMonth(slotTime.getMonth() + 1); maxSlots -= 1; if (maxSlots < 0) { // eslint-disable-next-line no-console @@ -452,12 +457,41 @@ export default class SkylineChart extends AbstractChart { async draw() { const params = new URL(window.location).searchParams; - const view = ['week', 'month', 'year'].indexOf(params.get('view')) !== -1 - ? params.get('view') - : 'week'; - // TODO re-add. I think this should be a filter + const view = params.get('view'); + // eslint-disable-next-line no-unused-vars - const endDate = params.get('endDate') ? `${params.get('endDate')}T00:00:00` : null; + const startDate = params.get('startDate'); + const endDate = params.get('endDate'); + + let customView = 'year'; + let unit = 'month'; + let units = 12; + if (view === 'custom') { + const diff = endDate ? new Date(endDate).getTime() - new Date(startDate).getTime() : 0; + if (diff < (1000 * 60 * 60 * 24)) { + // less than a day + customView = 'hour'; + unit = 'hour'; + units = 24; + } else if (diff <= (1000 * 60 * 60 * 24 * 7)) { + // less than a week + customView = 'week'; + unit = 'hour'; + units = Math.round(diff / (1000 * 60 * 60)); + } else if (diff <= (1000 * 60 * 60 * 24 * 31)) { + // less than a month + customView = 'month'; + unit = 'day'; + units = 30; + } else if (diff <= (1000 * 60 * 60 * 24 * 365 * 3)) { + // less than 3 years + customView = 'week'; + unit = 'week'; + units = Math.round(diff / (1000 * 60 * 60 * 24 * 7)); + } + } + + const focus = params.get('focus'); if (this.dataChunks.filtered.length < 1000) { this.elems.lowDataWarning.ariaHidden = 'false'; @@ -470,18 +504,32 @@ export default class SkylineChart extends AbstractChart { view, unit: 'day', units: 30, + focus, + startDate, endDate, }, week: { view, unit: 'hour', units: 24 * 7, + focus, + startDate, endDate, }, year: { view, unit: 'week', units: 52, + focus, + startDate, + endDate, + }, + custom: { + view: customView, + unit, + units, + focus, + startDate, endDate, }, }; @@ -559,6 +607,7 @@ export default class SkylineChart extends AbstractChart { this.stepSize = undefined; this.clsAlreadyLabeled = false; this.lcpAlreadyLabeled = false; + this.chart.update(); // add trend indicators diff --git a/tools/oversight/elements/daterange-picker.js b/tools/oversight/elements/daterange-picker.js new file mode 100644 index 00000000..80c50667 --- /dev/null +++ b/tools/oversight/elements/daterange-picker.js @@ -0,0 +1,392 @@ +function debounce(func, wait) { + let timeout; + // eslint-disable-next-line func-names + return function (...args) { + const context = this; + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(context, args), wait); + }; +} + +// date management +function pad(number) { + return number.toString().padStart(2, '0'); +} + +function toDateString(date) { + // convert date + const year = date.getFullYear(); + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + + return `${year}-${month}-${day}`; +} + +const STYLES = ` + .daterange-wrapper { + display: grid; + align-items: end; + gap: var(--spacing-l); + font-size: var(--type-body-s-size); + } + + .input-wrapper, .daterange-wrapper { + position: relative; + display: block; + } + + input { + width: 100%; + font: inherit; + border-color: var(--gray-100); + border: 2px solid var(--gray-300); + padding: 0.4em 0.85em 0.1em; + background-color: var(--gray-100); + cursor: pointer; + transition: border-color 0.2s, background-color 0.2s; + border-radius: 4px; + } + + input ~ ul li { + padding: 0.4em 0; + padding-left: 2rem; + cursor: pointer; + } + + ul.menu li:last-child { + position: relative; + margin-top: 16px; + } + + ul.menu li:last-child::before { + content: ''; + position: absolute; + top: calc((-0.5 * 16px) - (2px / 2)); + left: 0; + right: 0; + height: 2px; + background-color: var(--gray-200); + } + + .input-wrapper { + display: none; + background-color: white; + } + + input[data-custom='true'] ~ .input-wrapper { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px 12px; + right: 0; + margin-top: 4px; + border-radius: 4px; + padding: calc(0.4em + 2px); + background-color: white; + border: 1px solid var(--gray-100); + box-shadow: 5px 5px 5px var(--gray-700); + z-index: 10; + } + + ul { + position: relative; + list-style: none; + left: 0; + right: 0; + margin: 0; + border-radius: 8px; + padding: calc(0.4em + 2px); + background-color: white; + border: 1px solid var(--gray-100); + box-shadow: 5px 5px 5px var(--gray-700); + z-index: 20; + } + + ul.menu:not([hidden]) + .input-wrapper { + display: none; + } + + .date-field { + display: block; + margin-top: 0; + } + + .date-field label { + display: block; + } + + @media (width >= 740px) { + .input-wrapper { + grid-template-columns: repeat(2, 1fr); + } + + input[data-custom='true'] ~ .input-wrapper { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media (width >= 900px) { + .daterange-wrapper { + font-size: var(--type-body-l-size); + } + + ul { + position: absolute; + } + + input { + min-width: 200px; + } + + .input-wrapper { + min-width: 600px; + } + + input[data-custom='true'] ~ .input-wrapper { + grid-template-columns: minmax(0, 1fr); + } + + .date-field { + display: flex; + align-items: center; + gap: 16px; + } + + .date-field label { + margin-top: 4px; + } + } + + @media (width >= 1200px) { + input[data-custom='true'] ~ .input-wrapper { + grid-template-columns: repeat(2, minmax(0, 1fr)); + position: absolute; + } + } +`; + +const TEMPLATE = ` +
+ + + +
+`; + +export default class TimeRangePicker extends HTMLElement { + constructor() { + super(); + + this.inputElement = null; + this.dropdownElement = null; + this.fromElement = null; + this.toElement = null; + this.datetimeWrapperElement = null; + + this.id = this.getAttribute('id'); + } + + connectedCallback() { + this.initDOM(); + this.initValue(); + } + + initDOM() { + this.attachShadow({ mode: 'open' }); + + const style = document.createElement('style'); + style.textContent = STYLES; + + const section = document.createRange().createContextualFragment(TEMPLATE); + + const sul = section.querySelector('ul'); + const ul = this.querySelector('ul'); + const options = this.querySelectorAll('ul li'); + + options.forEach((option) => { + if (option.getAttribute('aria-selected') !== 'true') { + option.setAttribute('aria-selected', false); + } + option.dataset.role = 'option'; + sul.appendChild(option); + }); + + ul.remove(); + + this.shadowRoot.innerHTML = ''; + + this.shadowRoot.appendChild(style); + this.shadowRoot.appendChild(section); + + this.inputElement = this.shadowRoot.querySelector('input'); + this.dropdownElement = this.shadowRoot.querySelector('ul'); + this.fromElement = this.shadowRoot.querySelector('[name="date-from"]'); + this.toElement = this.shadowRoot.querySelector('[name="date-to"]'); + this.datetimeWrapperElement = this.shadowRoot.querySelector('.input-wrapper'); + + this.registerListeners(); + } + + initValue() { + const { toElement } = this; + toElement.value = toDateString(new Date()); + } + + registerListeners() { + const { inputElement, dropdownElement } = this; + const options = dropdownElement.querySelectorAll('li'); + + inputElement.addEventListener('click', () => { + const expanded = inputElement.getAttribute('aria-expanded') === 'true'; + inputElement.setAttribute('aria-expanded', !expanded); + dropdownElement.hidden = expanded; + }); + + const $this = this; + options.forEach((option) => { + option.addEventListener('click', () => { + $this.value = { + value: option.dataset.value, + from: $this.fromElement.value || null, + to: $this.toElement.value || null, + }; + }); + }); + + const setValue = () => { + $this.value = { + value: 'custom', + from: this.fromElement.value, + to: this.toElement.value, + }; + }; + + this.fromElement.addEventListener('change', debounce(setValue, 500)); + this.toElement.addEventListener('change', debounce(setValue, 500)); + } + + get value() { + return { + value: this.inputElement.dataset.value, + from: this.fromElement.value, + to: this.toElement.value, + }; + } + + set value(config) { + const { value, from, to } = config; + const { + inputElement, dropdownElement, fromElement, toElement, + } = this; + + const option = dropdownElement.querySelector(`li[data-value="${value}"]`); + if (!option) { + return; + } + + inputElement.value = option.textContent; + inputElement.dataset.value = option.dataset.value; + inputElement.setAttribute('aria-expanded', false); + + const options = dropdownElement.querySelectorAll('li'); + options.forEach((o) => o.setAttribute('aria-selected', o === option)); + + dropdownElement.hidden = true; + + let dateFrom = new Date(from); + let dateTo = new Date(to); + if (dateFrom > dateTo) { + // swap the 2 dates + dateFrom = new Date(to); + dateTo = new Date(from); + } + + if (from) { + fromElement.value = toDateString(dateFrom); + } + + if (to) { + toElement.value = toDateString(dateTo); + } + + this.updateTimeframe({ + value, + from: this.fromElement.value, + to: this.toElement.value, + }); + + this.dispatchEvent(new Event('change', { + detail: { + value, + from: this.fromElement.value, + to: this.toElement.value, + }, + })); + } + + updateTimeframe({ value }) { + // maintain the readonly state of the date fields and default value + const { fromElement, toElement } = this; + + const now = new Date(); + + [fromElement, toElement].forEach((field) => { + field.readOnly = true; + }); + this.toggleCustomTimeframe(value === 'custom'); + + if (value === 'week') { + if (!fromElement.value) { + const lastWeek = now; + lastWeek.setHours(-7 * 24, 0, 0, 0); + fromElement.value = toDateString(lastWeek); + } + if (!toElement.value) { + toElement.value = toDateString(now); + } + } else if (value === 'month') { + if (!fromElement.value) { + const lastMonth = now; + lastMonth.setMonth(now.getMonth() - 1); + fromElement.value = toDateString(lastMonth); + } + if (!toElement.value) { + toElement.value = toDateString(now); + } + } else if (value === 'year') { + if (!fromElement.value) { + const lastYear = now; + lastYear.setFullYear(now.getFullYear() - 1); + fromElement.value = toDateString(lastYear); + } + if (!toElement.value) { + toElement.value = toDateString(now); + } + } else if (value === 'custom') { + [fromElement, toElement].forEach((field) => { + field.removeAttribute('readonly'); + }); + } + } + + toggleCustomTimeframe(enabled) { + const { inputElement, datetimeWrapperElement } = this; + + inputElement.dataset.custom = enabled; + datetimeWrapperElement.hidden = !enabled; + [...datetimeWrapperElement.children].forEach((child) => { + child.setAttribute('aria-hidden', !enabled); + }); + } +} diff --git a/tools/oversight/explorer.html b/tools/oversight/explorer.html index 8414b952..5db9e7db 100644 --- a/tools/oversight/explorer.html +++ b/tools/oversight/explorer.html @@ -22,6 +22,7 @@ import VitalsFacet from './elements/vitals-facet.js'; import URLSelector from './elements/url-selector.js'; import NumberFormat from './elements/number-format.js'; + import DateRangePicker from './elements/daterange-picker.js'; window.slicer = { Chart: SkylineChart, }; @@ -35,6 +36,7 @@ customElements.define('url-selector', URLSelector); customElements.define('conversion-tracker', ConversionTracker); customElements.define('number-format', NumberFormat); + customElements.define('daterange-picker', DateRangePicker); @@ -48,14 +50,15 @@
www.aem.live -
- - -
+ + + +