Growth

Last updated •

Only 39% got from date to time

Six in ten sessions that picked a date on our booking screen never picked a time. How the step was measured, what was wrong with the measurement, what the page was doing to people, and why I still can't quote a clean after number. · 9 min read

I run the booking platform for a photo studio in Tokyo. A customer picks a plan, a location, a date and a time, fills in three contact fields, and pays a deposit. Almost all of them do it on a phone, and Instagram, opened in its in-app browser, is the largest source.

In the first eighteen days of August, 39% of sessions that selected a date went on to select a time. The other six in ten stopped there. That is an odd place to stop. Someone who has chosen a plan, a location and a date has already decided. This post is about how that number was produced, what turned out to be wrong with the page, and what turned out to be wrong with my own measuring.

39%

of sessions with a date selected also selected a time (Aug 1–18, GA4 sessions)

40% · 1 s

of a section on screen, for this long, before it counts as viewed

~800 ms

of scripted movement after a date tap that fought the visitor's finger

A bookings number does not say where

GA4 went onto the site on June 22 and the first funnel events on July 19. For weeks the booking screen itself was a blind spot: it recorded that a date was chosen and that Pay was pressed, and nothing in between. Picking a time, the step right after the one where most people stopped, was not an event at all.

Before adding more events I had to repair the ones I had. The inline GA4 snippet flushed my queued events before it had called config. Events sent to an unconfigured GA4 arrive with no session, no campaign and no page, so those visits were filed under "(not set)". Slow in-app browsers queue the most events, which meant Instagram traffic, the main source, was the worst recorded. A second gap: the checkout event fired only after every form check had passed, so a visitor who pressed Pay and was stopped by validation looked the same as one who never pressed it. I added an event for the blocked press with the reason attached.

What counts as seeing something

On August 8 I added six events inside the booking screen: the time list, the price summary, the contact form and the terms each report once they have been on screen, plus one for choosing a time and one for the first keystroke in the form. The whole page is in the DOM from the moment it loads, so "rendered" proves nothing. A section counts as viewed only when 40% of it has been visible for a full second.

apps/web/src/lib/useSectionViewed.ts
/** Enough of the section on screen to count as seen. */const VISIBLE_RATIO = 0.4;/** Long enough to be looking at it rather than scrolling past it. */const DWELL_MS = 1_000;// ...inside the hook:const observer = new IntersectionObserver(  (entries) => {    const entry = entries[0];    if (!entry) return;    if (!entry.isIntersecting || entry.intersectionRatio < VISIBLE_RATIO) {      // Scrolled away before the dwell elapsed: it was passed, not read.      clear();      return;    }    if (timer !== null || firedRef.current) return;    timer = window.setTimeout(() => {      timer = null;      if (firedRef.current) return;      firedRef.current = true;      observer.disconnect();      trackAnalyticsEvent(eventName, {        flow: 'standard_booking',        ...(parametersRef.current ?? {}),      });    }, DWELL_MS);  },  { threshold: [VISIBLE_RATIO] },);
Trimmed from the hook. Each event fires once per mount.

On these events, parameters are buckets, never raw values. The time-list event carries how many slots the chosen date had open as one of five bands (zero, one, two to three, four to eight, nine or more). The summary event carries a price band. A raw date or amount registered as a GA4 dimension has too many distinct values to group by. No field contents are sent at all; the form event says which field was touched first, not what was typed.

Likely cause one: the page took the scroll away

One thing the new events showed was that about half of the sessions that chose a date never dwelt on the time list. They did not look at the times and reject them. They never properly saw them.

Reading my own code explained it. On a phone, tapping a date started a sequence: a pause, the calendar folding away, then a scroll to the time list. The three timing constants are 280, 300 and 220 ms. Nothing cancelled it. If the visitor put a finger on the screen during those 800 ms, the page dragged itself back to where the script wanted it. The fix is small. Any deliberate input ends the animation.

apps/web/src/app/booking/[id]/page.tsx
function takeOverOnUserInput() {  releaseViewportAnimationHandoff();  const stop = () => cancelViewportAnimation();  // `touchstart` fires before the scroll it causes, so the animation is gone  // by the time the page would have fought it.  const events: Array<keyof WindowEventMap> = ['touchstart', 'wheel', 'keydown'];  for (const name of events) {    window.addEventListener(name, stop, { passive: true });  }  viewportHandoffRef.current = () => {    for (const name of events) window.removeEventListener(name, stop);  };}
Called when the scripted scroll starts. The animation is an offer to move the page, not a claim on it.

The same commit fixed a second thing. Sessions run from 12:00 to 22:00 Tokyo time, and nothing said so until a date had been picked and the times appeared. Anyone planning a morning shoot went through plan, location and date before finding out. The calendar now states the hours above the dates.

Likely cause two: one long page

My first guess for the rest was availability: people open a date, see few times, and leave. The slot-count bucket killed that guess. In the days measured, every visit that viewed the time list, 87 of 87, was looking at a date with nine or more slots open. 37 of them chose nothing. Nothing was full.

What the page did was let people slide past the thing they were meant to do next. Date, time, price, contact fields and terms all sat in one column. So on August 18 the screen became three steps: date and time, then contact details, then review and pay. Three decisions in that change mattered more than the split itself.

  • **One URL, not three routes.** Every screen change in this funnel was already costing between a third and a half of the people standing at it, and many of them are in an in-app browser. The steps switch what is shown; the page does not navigate.
  • **The button is never disabled for a missing field.** A greyed-out button is a tap that does nothing. Pressing it with something missing scrolls to the missing thing and records the same blocked-press reason as before, so that measure stayed comparable across the rewrite.
  • **Back means one step back.** Each forward move pushes a history entry, merged into the router's own state, so the phone's back gesture returns to the previous step instead of leaving the booking.

Replay, and the check that lied

GA4 says where sessions stop. It cannot show a finger fighting a scroll. I added Microsoft Clarity for session recordings, with the name, email and phone fields and the section around them masked, and recording switched off entirely on the deposit result pages.

It took three deploys to record anything. First the site's Content-Security-Policy refused to run the script. Clarity needs two hosts allowed, one for the per-project bootstrap and one for the library it loads. Then, with the policy opened, the bootstrap threw on its first line. I had mounted the tag with the id "clarity". An element with an id becomes a property of window, so window.clarity was already the script element when Clarity tried to define it as a function.

My verification was the bug

I had checked the install by evaluating typeof window.clarity and getting "object". That was the script tag answering for itself. The check passed on a recorder that had never run. Since then I confirm a measurement tool by the data arriving, not by the tag being present.

The recordings were more useful for a different step than the one I installed them for. They showed visitors tapping plan cards on the plans screen, often several times, where only a small text link was clickable. Clarity counted dead clicks in 15.79% of sessions. The card is now tappable, with a 10-pixel threshold to tell a tap from a swipe, because that carousel moves by CSS transform and the browser fires a click at the end of every swipe.

What I got wrong

  • **I predicted the wrong blocker.** I expected "no time selected" to be the main reason a button press was refused. In August the main reason was an empty name field: it stopped 103 of the 241 who pressed the button.
  • **A fix for that made it worse.** On August 27 the details step started opening with the cursor in the name field. On a phone that raised the keyboard, and the first tap elsewhere blurred an empty field and printed a red error before anything was typed. 33 visitors saw that error in five days and only 19 of them had typed a name. I removed the autofocus a month later.
  • **I judged a change with the instrument I had just changed.** On September 6 an ordered funnel went in next to the loose one. Its numbers dropped over the next two days and I suspected the deploy had broken checkout. The answer came from a system the deploy had not touched: booking notifications showed bookings still arriving, with the dip confined to one day.
apps/web/src/lib/bookingFunnel.ts
export const BOOKING_FUNNEL_MAX_IDLE_MS = 30 * 60 * 1_000;const checkoutStages: BookingFunnelStage[] = [  'booking_view',  'date_selected',  'time_selected',  'details_completed',  'review_completed',  'pay_clicked',  'stripe_redirected',  'booking_completed',];function canAdvance(stages: BookingFunnelStage[], currentIndex: number, stage: BookingFunnelStage) {  return stages[currentIndex + 1] === stage;}export function trackBookingFunnelStep(  stage: BookingFunnelStage,  contextOrNow: number | BookingFunnelContext = {},): boolean {  try {    return trackBookingFunnelStepUnguarded(stage, contextOrNow);  } catch {    return false;  }}
Trimmed from the ordered funnel. A stage is emitted only if it is the next one in order, in the same tab, with no gap over 30 minutes.

The try/catch is from that scare. Two of the tracker's calls sit directly in front of the action they describe, one before the booking request and one before checkout is created. Nothing in it was known to throw. The guard is there so that telemetry can never be the reason a payment does not start.

What the 39% does and does not show

  • It counts GA4 sessions, not people. One person on two visits is two sessions.
  • It compares two event counts. GA4 does not keep the order of events inside a session, so it shows where the volume falls, not a strict path. The ordered funnel exists because of this, and it only starts on September 6.
  • The time-selected event shipped on August 8. The baseline window in my own decision note is August 1–18. If the date count covered the first week and the time count could not, the real pass rate was higher than 39%. GA4's event-level retention was still at its two-month default until October 3, so the detail from before August 2 is gone and the window cannot be rebuilt in full.
  • There is no clean after number. The three-step screen shipped on August 18 and a price cut on August 19. In my experiment ledger the verdict on the three-step change is "cannot be separated". Later data points to the weakest step having moved one place along, to between choosing a time and finishing the contact details.
  • Staff sessions are excluded by a URL flag, and since October 6 only the production host is measured at all. Before that, a local dev server sent its visits into the real property. How much got in is unknown.

What I would keep from this: instrument the inside of the screen, not only its edges. Define "saw it" in a way a fast scroll cannot satisfy. Verify a measurement tool by its data. Change one thing at a time in one place, or accept that the result will be unreadable. And when the measuring changes, check the outcome somewhere the change did not reach.

Read more

Have something that needs to be built?

Tell me the workflow and what a good outcome looks like. I'll reply with a clear next step.

  • 1,200+ clients handled
  • Published at Mercari
  • 30K followers