diff --git a/.gitignore b/.gitignore
index 18c4b2bf..30bd87bb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,5 @@ MIGRATION_PLAN.md
.playwright-mcp/
.claude/settings.local.json
.claude/skills/tool-image-prompt/scripts/.cache/
+
+.claude/worktrees
diff --git a/tools/optel/oversight/elements/list-facet.js b/tools/optel/oversight/elements/list-facet.js
index be1cae51..90d3bbdd 100644
--- a/tools/optel/oversight/elements/list-facet.js
+++ b/tools/optel/oversight/elements/list-facet.js
@@ -115,6 +115,13 @@ export default class ListFacet extends HTMLElement {
if (sortMetric === 'count') return 0; // keep the default order from distiller
if (sortMetric === 'weight') return b.weight - a.weight; // order by weight, aka number of page views
if (sortMetric === 'value') return a.value.localeCompare(b.value);
+ if (sortMetric === 'asc') {
+ // numeric sort for histogram facets (e.g. "00:10 - 00:30")
+ const aNum = Number.parseFloat(a.value.replace(/[^0-9.]/g, ''));
+ const bNum = Number.parseFloat(b.value.replace(/[^0-9.]/g, ''));
+ if (Number.isNaN(aNum) || Number.isNaN(bNum)) return a.value.localeCompare(b.value);
+ return aNum - bNum;
+ }
// get metric and property (e.g. lcp.mean)
const aNum = a.metrics[sortMetric][sortProperty];
const bNum = b.metrics[sortMetric][sortProperty];
diff --git a/tools/optel/oversight/explorer.html b/tools/optel/oversight/explorer.html
index 48b56c83..81957f74 100644
--- a/tools/optel/oversight/explorer.html
+++ b/tools/optel/oversight/explorer.html
@@ -462,20 +462,25 @@
TTFB
-
+
+
-
+
+
-
+
+
+
+
+
+
diff --git a/tools/optel/oversight/slicer.js b/tools/optel/oversight/slicer.js
index 3465eeb4..f4f53bc7 100644
--- a/tools/optel/oversight/slicer.js
+++ b/tools/optel/oversight/slicer.js
@@ -77,6 +77,28 @@ dataChunks.addSeries('timeOnPage', (bundle) => {
return (deltas.reduce((a, b) => Math.max(a, b), -Infinity)) / 1000;
});
+function formatTimeToMMSS(seconds) {
+ if (seconds === undefined || seconds === null || Number.isNaN(seconds)) {
+ return undefined;
+ }
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = Math.floor(seconds % 60);
+ return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
+}
+
+function timeToSubmit(bundle) {
+ const filteredEvents = bundle.events.filter((evt) => evt.checkpoint === 'formsubmit' || evt.checkpoint === 'viewblock');
+ const submitEvent = filteredEvents.find((evt) => evt.checkpoint === 'formsubmit');
+ if (!submitEvent) {
+ return undefined;
+ }
+ const formViewEvent = filteredEvents.find((evt) => evt.checkpoint === 'viewblock' && evt.source === submitEvent.source);
+ if (!formViewEvent) {
+ return undefined;
+ }
+ return (submitEvent.timeDelta - formViewEvent.timeDelta) / 1000;
+}
+
dataChunks.addSeries('contentEngagement', (bundle) => {
const viewEvents = bundle.events
.filter((evt) => evt.checkpoint === 'viewmedia' || evt.checkpoint === 'viewblock');
@@ -213,6 +235,8 @@ function updateDataFacets(filterText, params, checkpoint) {
dataChunks.addFacet('checkpoint', facets.checkpoint, 'every', 'none');
+ dataChunks.addFacet('formsubmit.time', timeToSubmit);
+
dataChunks.addFacet(
'conversions',
(bundle) => (dataChunks.hasConversion(bundle, conversionSpec) ? 'converted' : 'not-converted'),
@@ -285,6 +309,19 @@ function updateDataFacets(filterText, params, checkpoint) {
);
}
+ if (cp === 'formsubmit') {
+ dataChunks.addHistogramFacet(
+ 'formsubmit.histogram',
+ 'formsubmit.time',
+ {
+ count: 10, min: 0, max: 600, steps: 'quantiles',
+ },
+ {
+ format: formatTimeToMMSS,
+ },
+ );
+ }
+
// a bit of special handling here, so we can split the acquisition source
if (cp === 'acquisition') {
dataChunks.addFacet('acquisition.source', acquisitionSource);