DB Olympics

Queries · q287 · VP Product · week 2, night 1

Cart contents: stalled vs. fast-converting carts, 2025

“Does basket size, price point, or discount usage differ between carts that convert quickly and carts that stall for hours or days?”

Manager · asked the questionJonas FeldVP Product. Joined from a mobile-first retailer. Deciding the 2026 roadmap.
Analyst · planned the reportEmeka ObiCustomer-centric: follows cohorts of shoppers or merchants over time.
Engineer · wrote the SQLOmarPragmatic: the shortest correct query, aggregates first and joins dimensions afterwards, FILTER clauses for conditional counts.

Fictional people at a fictional company, each played by a language-model agent. Who they are and what they are told

  1. A business event lands on the VP Product's desk

    Week 1

    We are redesigning the shopper journey for 2026. We need to understand the whole path, from the first visit to checkout, delivery, returns and the next purchase, and where shoppers drop out and why.

  2. Jonas Feld writes to Emeka Obi

    The manager, at the start of week 2

    Subject: Good start — let's dig into the cart leak

    Emeka, this is exactly the kind of clarity I needed. The cart-stage bleed is the headline, and I agree we should lead with it — but a two-thirds drop-off that lingers for days, converting at under 20%, isn't something we can act on until we know why people stall. "Add cart recovery emails" is a guess until we understand the shape of the hesitation. That's my main ask for this week.

    I also want to flag one thing before we build a roadmap slide on repeat purchase and returns: the return-vs-repeat gap and the delivery/returns pattern are both flagged as not-yet-causal, confounded by vertical. I don't want to present an 8-point "returns hurt loyalty" number to the exec team if it's really just "apparel behaves differently." Let's firm that up before it goes in a deck, but it's not urgent this week.

    Questions for this week:

    1. What does a stalled cart actually contain, and does it differ from a fast-converting one? I'm thinking about basket size, price point, discount/coupon presence — anything that hints at why someone hesitates for hours or days instead of minutes.
    2. Is the cart bleed getting better, worse, or flat over the past year? If it's been stable at ~70% for two years, that's a structural product problem; if it's worsening recently, something changed (a checkout step, a fee, a release) that we should find and reverse.
    3. Of the carts that eventually do convert after sitting a while, is there a nudge already happening (a reminder, a return visit, a discount) that we could learn from and do more deliberately? I want to know if recovery is already happening organically before we invest in prompting it.
    4. On the subscription merchants' tracking gap — is this just a blind spot in how we instrument recurring orders, or is it telling us something about how those shoppers actually behave (e.g., they never browse again after the first signup)? A quick gut-check is enough this week; no need for a deep investigation yet.

    Keep the returns/delivery vertical-control work and the causal retention question on the list for whenever a slot frees up, but the cart story is what I want to walk into the roadmap review with real conviction on.

    Thanks, Emeka — strong week.

    Jonas

  3. Emeka Obi plans the night

    The analyst · week 2, night 1, request 1 of 3

    Jonas's email has an implicit priority: Q1 (what's in a stalled cart) and Q2 (is the bleed trending worse) are "my main ask for this week"; Q3 (organic recovery) is also wanted but reads as a natural follow-up once we understand the shape; Q4 is explicitly "a quick gut-check... no need for deep investigation." So tonight I'm doing Q1, Q2, and a lightweight version of Q4, saving Q3 for night 2 since it benefits from what we learn tonight (e.g., whether stalled carts later convert with a discount attached is itself a clue about organic recovery).

    Design notes:

    • For Q1, carts.item_count and carts.subtotal are populated regardless of cart status, so I can compare basket size across ALL carts by duration bucket. Discount/coupon presence, however, only exists on the order side (order_discounts/coupon_redemptions) — there's nothing on an abandoned cart to show a discount was being attempted. So I've scoped the discount question to "among carts that did convert, how often did the resulting order carry a discount, by how long the cart took to convert" — flagged clearly as a narrower sub-question, not a true stalled-vs-fast comparison on discounts.
    • For Q2, I'm using the same 5 duration buckets from last week's cart-timing reports, by month across all of 2025, to see if the mix is shifting (e.g., more carts falling into the 1-7 day "bleed" bucket recently) or if the overall cart→order conversion rate itself is moving. I'll also watch for anything around November 2025, since Platform Engineering flagged a cart-reminder email bug that week — relevant context, not something I'm acting on yet.
    • For Q4 ("gut check"), I'm testing the blind-spot-vs-behavior question directly: for the 4 subscription merchants, do customers have tracked sessions before their first order (proving tracking works) but little or none after (suggesting they genuinely stop browsing once subscribed) — vs. near-zero sessions even before the first order (which would point to a tracking gap instead).

    All three use the 6 retail tenants (Q1, Q2) or 4 subscription tenants (Q4) established in last week's work, so the engineer has a concrete, named population to filter on rather than re-deriving "top merchants."

    Cart contents: stalled vs. fast-converting carts, 2025
    Question
    Does basket size, price point, or discount usage differ between carts that convert quickly and carts that stall for hours or days?
    One row per
    cart duration bucket
    Columns
    • duration bucket
    • cart count
    • avg item count
    • avg subtotal (EUR)
    • median subtotal (EUR)
    • pct of bucket that converted to an order
    • pct of converted carts (in this bucket) whose order has a discount or coupon applied
  4. Omar writes the SQL

    The engineer · 1 dry run on the empty schema, then the submission

    Dry run 1 · STATUS: COMPILES (dry run on the empty schema, no data read)
    WITH tenant_filter AS (
      SELECT id FROM tenants WHERE name IN ('Kibblewick','Studio Vrij','Petalcourt Beauty','Marrowby & Vane','Cragsley Outfitters','Ampfeld Elektronik')
    ),
    cart_base AS (
      SELECT c.id, c.tenant_id, c.status, c.item_count, c.subtotal, c.currency_code, c.created_at,
             coalesce(c.converted_at, c.abandoned_at, c.expires_at) AS resolved_at,
             er.rate AS eur_rate
      FROM carts c
      JOIN tenant_filter tf ON tf.id = c.tenant_id
      LEFT JOIN exchange_rates er ON er.base_code='EUR' AND er.quote_code = c.currency_code AND er.as_of = c.created_at::date
      WHERE c.created_at >= DATE '2025-01-01' AND c.created_at < DATE '2026-01-01'
    ),
    cart_calc AS (
      SELECT *,
        CASE WHEN currency_code='EUR' THEN subtotal ELSE subtotal / NULLIF(eur_rate,0) END AS subtotal_eur,
        CASE WHEN resolved_at IS NULL THEN NULL ELSE EXTRACT(EPOCH FROM (resolved_at - created_at))/3600.0 END AS resolution_hours
      FROM cart_base
    ),
    bucketed AS (
      SELECT *,
        CASE
          WHEN resolution_hours IS NULL THEN 'unresolved/still active'
          WHEN resolution_hours < 1 THEN '<1 hour'
          WHEN resolution_hours < 24 THEN '1-24 hours'
          WHEN resolution_hours < 168 THEN '1-7 days'
          ELSE '>7 days'
        END AS duration_bucket
      FROM cart_calc
    ),
    order_discount_flag AS (
      SELECT o.cart_id, CASE WHEN EXISTS (SELECT 1 FROM order_discounts od WHERE od.order_id = o.id) THEN 1 ELSE 0 END AS has_discount
      FROM orders o
      WHERE o.cart_id IS NOT NULL
    )
    SELECT
      b.duration_bucket AS duration_bucket,
      COUNT(*) AS cart_count,
      ROUND(AVG(b.item_count)::numeric, 2) AS avg_item_count,
      ROUND(AVG(b.subtotal_eur)::numeric, 2) AS avg_subtotal_eur,
      ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY b.subtotal_eur)::numeric, 2) AS median_subtotal_eur,
      ROUND(100.0 * COUNT(*) FILTER (WHERE b.status='converted') / COUNT(*), 1) AS pct_converted,
      ROUND(100.0 * COUNT(*) FILTER (WHERE b.status='converted' AND odf.has_discount=1) / NULLIF(COUNT(*) FILTER (WHERE b.status='converted'),0), 1) AS pct_converted_with_discount
    FROM bucketed b
    LEFT JOIN order_discount_flag odf ON odf.cart_id = b.id
    GROUP BY b.duration_bucket
    ORDER BY CASE b.duration_bucket
      WHEN '<1 hour' THEN 1
      WHEN '1-24 hours' THEN 2
      WHEN '1-7 days' THEN 3
      WHEN '>7 days' THEN 4
      WHEN 'unresolved/still active' THEN 5
    END

    “Looks good — compiles with the expected columns, grain, and ordering.”

    Submitted · the SQL of the last dry run, unchanged · Submitted to tonight's batch. Your work on this request is done.

    “Submitted. The report is grained by duration bucket (5 rows, fixed order), covering the 6 named retail tenants' 2025 carts, with EUR-normalized subtotal averages/medians, conversion rate, and discount-usage rate among converted carts per bucket.”

  5. The report comes back overnight

    As Emeka Obi saw it, from the small dataset the agents worked on

    duration_bucket | cart_count | avg_item_count | avg_subtotal_eur | median_subtotal_eur | pct_converted | pct_converted_with_discount
    ----------------+------------+----------------+------------------+---------------------+---------------+----------------------------
    <1 hour         |       5992 |           2.79 |           242.44 |              130.92 |         100.0 |                        17.2
    1-24 hours      |       6577 |           1.61 |           175.87 |               75.06 |           1.8 |                        16.2
    1-7 days        |       8293 |           1.74 |           168.84 |               77.63 |          16.0 |                        10.7
    >7 days         |        335 |           1.79 |           209.01 |               84.40 |          31.6 |                        15.1
    (4 rows)
  6. What came of it

    The memo and the queries that followed; links found by title, so inferred

    Later queries that build on this report:

    It builds on:

  7. It becomes benchmark query q287

    Changed only where the benchmark needs the same result on every system

    • spellingcast (4×)
    • numericsdocs/NUMERICS.md: rounding removed; results keep full precision; bare NUMERIC cast that only served the rounding removed; AVG over an integer computed as a decimal: AVG(CAST(x AS NUMERIC(18,6)))
    • numericsdocs/NUMERICS.md: EXTRACT(EPOCH FROM (b - a)) cast to NUMERIC(18,6)
    • numericsdocs/NUMERICS.md: x / exchange rate cast to NUMERIC(18,6)
    • made explicita selected t.col gets AS col, its standard name made explicit (8×)
    • placeholderpercentile cont
    • placeholderseconds between
    • determinismthe standard leaves the place of NULLs in a sort to the implementation: NULLS LAST ascending, NULLS FIRST descending, as in the reference results
    The benchmark text, against the submitted SQL
    WITH tenant_filter AS (  SELECT id FROM tenants WHERE name IN ('Kibblewick','Studio Vrij','Petalcourt Beauty','Marrowby & Vane','Cragsley Outfitters','Ampfeld Elektronik')),cart_base AS (  SELECT c.id, c.tenant_id, c.status, c.item_count, c.subtotal, c.currency_code, c.created_at,  SELECT c.id AS id, c.tenant_id AS tenant_id, c.status AS status, c.item_count AS item_count, c.subtotal AS subtotal, c.currency_code AS currency_code, c.created_at AS created_at,         coalesce(c.converted_at, c.abandoned_at, c.expires_at) AS resolved_at,         er.rate AS eur_rate  FROM carts c  JOIN tenant_filter tf ON tf.id = c.tenant_id  LEFT JOIN exchange_rates er ON er.base_code='EUR' AND er.quote_code = c.currency_code AND er.as_of = c.created_at::date  LEFT JOIN exchange_rates er ON er.base_code='EUR' AND er.quote_code = c.currency_code AND er.as_of = CAST(c.created_at AS date)  WHERE c.created_at >= DATE '2025-01-01' AND c.created_at < DATE '2026-01-01'),cart_calc AS (  SELECT *,    CASE WHEN currency_code='EUR' THEN subtotal ELSE subtotal / NULLIF(eur_rate,0) END AS subtotal_eur,    CASE WHEN resolved_at IS NULL THEN NULL ELSE EXTRACT(EPOCH FROM (resolved_at - created_at))/3600.0 END AS resolution_hours    CASE WHEN currency_code='EUR' THEN subtotal ELSE CAST(subtotal / NULLIF(eur_rate,0) AS NUMERIC(18,6)) END AS subtotal_eur,    CASE WHEN resolved_at IS NULL THEN NULL ELSE {{seconds_between(created_at, resolved_at)}}/3600.0 END AS resolution_hours  FROM cart_base),bucketed AS (  SELECT *,    CASE      WHEN resolution_hours IS NULL THEN 'unresolved/still active'      WHEN resolution_hours < 1 THEN '<1 hour'      WHEN resolution_hours < 24 THEN '1-24 hours'      WHEN resolution_hours < 168 THEN '1-7 days'      ELSE '>7 days'    END AS duration_bucket  FROM cart_calc),order_discount_flag AS (  SELECT o.cart_id, CASE WHEN EXISTS (SELECT 1 FROM order_discounts od WHERE od.order_id = o.id) THEN 1 ELSE 0 END AS has_discount  SELECT o.cart_id AS cart_id, CASE WHEN EXISTS (SELECT 1 FROM order_discounts od WHERE od.order_id = o.id) THEN 1 ELSE 0 END AS has_discount  FROM orders o  WHERE o.cart_id IS NOT NULL)SELECT  b.duration_bucket AS duration_bucket,  COUNT(*) AS cart_count,  ROUND(AVG(b.item_count)::numeric, 2) AS avg_item_count,  ROUND(AVG(b.subtotal_eur)::numeric, 2) AS avg_subtotal_eur,  ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY b.subtotal_eur)::numeric, 2) AS median_subtotal_eur,  ROUND(100.0 * COUNT(*) FILTER (WHERE b.status='converted') / COUNT(*), 1) AS pct_converted,  ROUND(100.0 * COUNT(*) FILTER (WHERE b.status='converted' AND odf.has_discount=1) / NULLIF(COUNT(*) FILTER (WHERE b.status='converted'),0), 1) AS pct_converted_with_discount  AVG(CAST(b.item_count AS NUMERIC(18,6))) AS avg_item_count,  AVG(b.subtotal_eur) AS avg_subtotal_eur,  {{percentile_cont(0.5, b.subtotal_eur)}} AS median_subtotal_eur,  100.0 * COUNT(*) FILTER (WHERE b.status='converted') / COUNT(*) AS pct_converted,  100.0 * COUNT(*) FILTER (WHERE b.status='converted' AND odf.has_discount=1) / NULLIF(COUNT(*) FILTER (WHERE b.status='converted'),0) AS pct_converted_with_discountFROM bucketed bLEFT JOIN order_discount_flag odf ON odf.cart_id = b.idGROUP BY b.duration_bucketORDER BY CASE b.duration_bucket  WHEN '<1 hour' THEN 1  WHEN '1-24 hours' THEN 2  WHEN '1-7 days' THEN 3  WHEN '>7 days' THEN 4  WHEN 'unresolved/still active' THEN 5ENDEND NULLS LAST
    WITH tenant_filter AS (
      SELECT id FROM tenants WHERE name IN ('Kibblewick','Studio Vrij','Petalcourt Beauty','Marrowby & Vane','Cragsley Outfitters','Ampfeld Elektronik')
    ),
    cart_base AS (
      SELECT c.id AS id, c.tenant_id AS tenant_id, c.status AS status, c.item_count AS item_count, c.subtotal AS subtotal, c.currency_code AS currency_code, c.created_at AS created_at,
             coalesce(c.converted_at, c.abandoned_at, c.expires_at) AS resolved_at,
             er.rate AS eur_rate
      FROM carts c
      JOIN tenant_filter tf ON tf.id = c.tenant_id
      LEFT JOIN exchange_rates er ON er.base_code='EUR' AND er.quote_code = c.currency_code AND er.as_of = CAST(c.created_at AS date)
      WHERE c.created_at >= DATE '2025-01-01' AND c.created_at < DATE '2026-01-01'
    ),
    cart_calc AS (
      SELECT *,
        CASE WHEN currency_code='EUR' THEN subtotal ELSE CAST(subtotal / NULLIF(eur_rate,0) AS NUMERIC(18,6)) END AS subtotal_eur,
        CASE WHEN resolved_at IS NULL THEN NULL ELSE CAST(EXTRACT(EPOCH FROM (resolved_at - created_at)) AS NUMERIC(18,6))/3600.0 END AS resolution_hours
      FROM cart_base
    ),
    bucketed AS (
      SELECT *,
        CASE
          WHEN resolution_hours IS NULL THEN 'unresolved/still active'
          WHEN resolution_hours < 1 THEN '<1 hour'
          WHEN resolution_hours < 24 THEN '1-24 hours'
          WHEN resolution_hours < 168 THEN '1-7 days'
          ELSE '>7 days'
        END AS duration_bucket
      FROM cart_calc
    ),
    order_discount_flag AS (
      SELECT o.cart_id AS cart_id, CASE WHEN EXISTS (SELECT 1 FROM order_discounts od WHERE od.order_id = o.id) THEN 1 ELSE 0 END AS has_discount
      FROM orders o
      WHERE o.cart_id IS NOT NULL
    )
    SELECT
      b.duration_bucket AS duration_bucket,
      COUNT(*) AS cart_count,
      AVG(CAST(b.item_count AS NUMERIC(18,6))) AS avg_item_count,
      AVG(b.subtotal_eur) AS avg_subtotal_eur,
      PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY b.subtotal_eur) AS median_subtotal_eur,
      100.0 * COUNT(*) FILTER (WHERE b.status='converted') / COUNT(*) AS pct_converted,
      100.0 * COUNT(*) FILTER (WHERE b.status='converted' AND odf.has_discount=1) / NULLIF(COUNT(*) FILTER (WHERE b.status='converted'),0) AS pct_converted_with_discount
    FROM bucketed b
    LEFT JOIN order_discount_flag odf ON odf.cart_id = b.id
    GROUP BY b.duration_bucket
    ORDER BY CASE b.duration_bucket
      WHEN '<1 hour' THEN 1
      WHEN '1-24 hours' THEN 2
      WHEN '1-7 days' THEN 3
      WHEN '>7 days' THEN 4
      WHEN 'unresolved/still active' THEN 5
    END NULLS LAST
    100 ms1 s10 sHyper669 msDuckDB886 msGizmoSQL713 msclickhouse_brew1.70 sFirebolterrorDataFusionerrorClickHouseerror
    • Firebolt{ "errors": [ { "description": "Line 15, Column 68: Operations between decimals with different precision and scale is not supported at the moment. Explicitly cast one of the decimals to other decimal precision and scale." } ], "query": { "query_id": "af739983-0511-4c93-b151-bf230f852454", "query_lab
    • DataFusionThis feature is not implemented: Physical plan does not support logical expression Exists(Exists { subquery: <subquery>, negated: false })
    • ClickHouseCode: 69. DB::Exception: Decimal result's scale is less than argument's one: while executing function divide on arguments __table3.subtotal Decimal(14, 4) Decimal64(size = 0), nullIf(__table3.eur_rate, 0_UInt8) Nullable(Decimal(12, 6)) Nullable(size = 0, Decimal64(size = 0), UInt8(size = 0)). (ARGUM
  8. Everything, as recorded

    The agents' own records, as the role play wrote them; only the personal data of the person who ran it is redacted

    Omar's whole session, 7 turns
    • The request, as Omar received it
      Report request from Emeka Obi (analyst for the VP Product):
      
      Title: Cart contents: stalled vs. fast-converting carts, 2025
      Question: Does basket size, price point, or discount usage differ between carts that convert quickly and carts that stall for hours or days?
      Grain: one row per cart duration bucket
      Columns:
      - duration bucket
      - cart count
      - avg item count
      - avg subtotal (EUR)
      - median subtotal (EUR)
      - pct of bucket that converted to an order
      - pct of converted carts (in this bucket) whose order has a discount or coupon applied
      Details: Tenants: the 6 retail merchants (Kibblewick, Studio Vrij, Petalcourt Beauty, Marrowby & Vane, Cragsley Outfitters, Ampfeld Elektronik). Population: carts with created_at in 2025-01-01 to 2025-12-31. Buckets (same definitions as last week): resolution_time = coalesce(converted_at, abandoned_at, expires_at) - created_at in hours; '<1 hour', '1-24 hours', '1-7 days', '>7 days', 'unresolved/still active' (no resolution timestamp). avg/median subtotal: carts.subtotal converted to EUR via exchange_rates (base_code='EUR', quote_code=carts.currency_code, as_of = carts.created_at date; amount_eur = subtotal / rate). avg item count = carts.item_count. pct_converted = share of carts in the bucket with status='converted'. Discount/coupon presence can only be observed on carts that converted (abandoned carts never reach checkout, so there's no discount record) — compute it only among converted carts in each bucket, as the % whose resulting order (orders.cart_id = carts.id) has at least one row in order_discounts. Order rows in the fixed sequence: '<1 hour', '1-24 hours', '1-7 days', '>7 days', 'unresolved/still active'.
      
      Check your query with the warehouse's dry_run tool, then hand it in with submit.
    • Omar calls dry_run
      WITH tenant_filter AS (
        SELECT id FROM tenants WHERE name IN ('Kibblewick','Studio Vrij','Petalcourt Beauty','Marrowby & Vane','Cragsley Outfitters','Ampfeld Elektronik')
      ),
      cart_base AS (
        SELECT c.id, c.tenant_id, c.status, c.item_count, c.subtotal, c.currency_code, c.created_at,
               coalesce(c.converted_at, c.abandoned_at, c.expires_at) AS resolved_at,
               er.rate AS eur_rate
        FROM carts c
        JOIN tenant_filter tf ON tf.id = c.tenant_id
        LEFT JOIN exchange_rates er ON er.base_code='EUR' AND er.quote_code = c.currency_code AND er.as_of = c.created_at::date
        WHERE c.created_at >= DATE '2025-01-01' AND c.created_at < DATE '2026-01-01'
      ),
      cart_calc AS (
        SELECT *,
          CASE WHEN currency_code='EUR' THEN subtotal ELSE subtotal / NULLIF(eur_rate,0) END AS subtotal_eur,
          CASE WHEN resolved_at IS NULL THEN NULL ELSE EXTRACT(EPOCH FROM (resolved_at - created_at))/3600.0 END AS resolution_hours
        FROM cart_base
      ),
      bucketed AS (
        SELECT *,
          CASE
            WHEN resolution_hours IS NULL THEN 'unresolved/still active'
            WHEN resolution_hours < 1 THEN '<1 hour'
            WHEN resolution_hours < 24 THEN '1-24 hours'
            WHEN resolution_hours < 168 THEN '1-7 days'
            ELSE '>7 days'
          END AS duration_bucket
        FROM cart_calc
      ),
      order_discount_flag AS (
        SELECT o.cart_id, CASE WHEN EXISTS (SELECT 1 FROM order_discounts od WHERE od.order_id = o.id) THEN 1 ELSE 0 END AS has_discount
        FROM orders o
        WHERE o.cart_id IS NOT NULL
      )
      SELECT
        b.duration_bucket AS duration_bucket,
        COUNT(*) AS cart_count,
        ROUND(AVG(b.item_count)::numeric, 2) AS avg_item_count,
        ROUND(AVG(b.subtotal_eur)::numeric, 2) AS avg_subtotal_eur,
        ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY b.subtotal_eur)::numeric, 2) AS median_subtotal_eur,
        ROUND(100.0 * COUNT(*) FILTER (WHERE b.status='converted') / COUNT(*), 1) AS pct_converted,
        ROUND(100.0 * COUNT(*) FILTER (WHERE b.status='converted' AND odf.has_discount=1) / NULLIF(COUNT(*) FILTER (WHERE b.status='converted'),0), 1) AS pct_converted_with_discount
      FROM bucketed b
      LEFT JOIN order_discount_flag odf ON odf.cart_id = b.id
      GROUP BY b.duration_bucket
      ORDER BY CASE b.duration_bucket
        WHEN '<1 hour' THEN 1
        WHEN '1-24 hours' THEN 2
        WHEN '1-7 days' THEN 3
        WHEN '>7 days' THEN 4
        WHEN 'unresolved/still active' THEN 5
      END
    • The warehouse answers
      STATUS: COMPILES (dry run on the empty schema, no data read)
      COLUMNS: duration_bucket, cart_count, avg_item_count, avg_subtotal_eur, median_subtotal_eur, pct_converted, pct_converted_with_discount
    • OmarLooks good — compiles with the expected columns, grain, and ordering.
    • Omar calls submit
      WITH tenant_filter AS (
        SELECT id FROM tenants WHERE name IN ('Kibblewick','Studio Vrij','Petalcourt Beauty','Marrowby & Vane','Cragsley Outfitters','Ampfeld Elektronik')
      ),
      cart_base AS (
        SELECT c.id, c.tenant_id, c.status, c.item_count, c.subtotal, c.currency_code, c.created_at,
               coalesce(c.converted_at, c.abandoned_at, c.expires_at) AS resolved_at,
               er.rate AS eur_rate
        FROM carts c
        JOIN tenant_filter tf ON tf.id = c.tenant_id
        LEFT JOIN exchange_rates er ON er.base_code='EUR' AND er.quote_code = c.currency_code AND er.as_of = c.created_at::date
        WHERE c.created_at >= DATE '2025-01-01' AND c.created_at < DATE '2026-01-01'
      ),
      cart_calc AS (
        SELECT *,
          CASE WHEN currency_code='EUR' THEN subtotal ELSE subtotal / NULLIF(eur_rate,0) END AS subtotal_eur,
          CASE WHEN resolved_at IS NULL THEN NULL ELSE EXTRACT(EPOCH FROM (resolved_at - created_at))/3600.0 END AS resolution_hours
        FROM cart_base
      ),
      bucketed AS (
        SELECT *,
          CASE
            WHEN resolution_hours IS NULL THEN 'unresolved/still active'
            WHEN resolution_hours < 1 THEN '<1 hour'
            WHEN resolution_hours < 24 THEN '1-24 hours'
            WHEN resolution_hours < 168 THEN '1-7 days'
            ELSE '>7 days'
          END AS duration_bucket
        FROM cart_calc
      ),
      order_discount_flag AS (
        SELECT o.cart_id, CASE WHEN EXISTS (SELECT 1 FROM order_discounts od WHERE od.order_id = o.id) THEN 1 ELSE 0 END AS has_discount
        FROM orders o
        WHERE o.cart_id IS NOT NULL
      )
      SELECT
        b.duration_bucket AS duration_bucket,
        COUNT(*) AS cart_count,
        ROUND(AVG(b.item_count)::numeric, 2) AS avg_item_count,
        ROUND(AVG(b.subtotal_eur)::numeric, 2) AS avg_subtotal_eur,
        ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY b.subtotal_eur)::numeric, 2) AS median_subtotal_eur,
        ROUND(100.0 * COUNT(*) FILTER (WHERE b.status='converted') / COUNT(*), 1) AS pct_converted,
        ROUND(100.0 * COUNT(*) FILTER (WHERE b.status='converted' AND odf.has_discount=1) / NULLIF(COUNT(*) FILTER (WHERE b.status='converted'),0), 1) AS pct_converted_with_discount
      FROM bucketed b
      LEFT JOIN order_discount_flag odf ON odf.cart_id = b.id
      GROUP BY b.duration_bucket
      ORDER BY CASE b.duration_bucket
        WHEN '<1 hour' THEN 1
        WHEN '1-24 hours' THEN 2
        WHEN '1-7 days' THEN 3
        WHEN '>7 days' THEN 4
        WHEN 'unresolved/still active' THEN 5
      END
    • The warehouse answers
      Submitted to tonight's batch. Your work on this request is done.
    • OmarSubmitted. The report is grained by duration bucket (5 rows, fixed order), covering the 6 named retail tenants' 2025 carts, with EUR-normalized subtotal averages/medians, conversion rate, and discount-usage rate among converted carts per bucket.