# Initiative 2.2 "En stock livraison 24h" — Baseline + Post Tracking Implementation Plan
> For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
> ⚠️ AS-BUILT CORRECTIONS (2026-05-26) — the committed script & spec are the source of truth.
> During implementation the backfilled data overturned two assumptions baked into the task
> text/code blocks below:
> 1. Badge column: the "En stock - Livraison 24h" badge is in PRODUCT_STOCK
> (value '1_available_En stock - Livraison 24h', match product_stock ILIKE '%livraison 24h%'),
> not PRODUCT_DELIVERY_TIME (which is a sparse numeric lead-time). So badge_split()
> queries product_stock and BADGE_LIKE = '%livraison 24h%'.
> 2. Go-live / cutoff: the badge value first appears 2026-05-20 (not 13/05), matching
> the backlog start_date. Windows are baseline 2026-02-13…05-19, post 2026-05-20…05-29.
> 3. The script also gained a common_max_date() cap so the post window only covers dates
> present in all three source tables (action/sessions/product) — avoids mixing a fresh
> product backfill with a staler action pull.
> Code/date/column literals in the tasks below are pre-correction; read them with these fixes.
Goal: Measure the product-view→add-to-cart→purchase funnel (PDP and vignette paths) on all FR traffic, before vs after the 2026-05-13 "En stock livraison 24h" PDP badge go-live, verify the badge is firing via the dimension25/PRODUCT_DELIVERY_TIME signal, and surface baseline-vs-actual KPIs in the Web Funnel Initiatives Backlog dashboard.
Architecture: A single idempotent analysis script (scripts/update_wf_test22_delivery_eta.py) reads ga4_action_events + ga4_sessions (funnels, both windows) and ga4_product_events (badge signal, post window), then writes wf_test_kpis rows and emits a result_markdown block. The badge columns (product_delivery_time, product_stock) are added to ga4_product_events and backfilled for FR over the post window only. No new dashboard endpoint/template — the existing /panel/wf-backlog already renders wf_test_kpis baseline/actual columns.
Tech Stack: Python 3.12 (project venv), psycopg2, TimescaleDB (:5434, ga4_), PostgreSQL (:5433, wf_), Oracle thin client (oracledb) for the ingestion backfill. Spec: docs/superpowers/specs/2026-05-26-initiative-2.2-delivery-eta-design.md.
Reference pattern: scripts/update_wf_test5_variants.py (Test 1) — same structure: compute → upsert wf_test_kpis → print human summary → emit ===RESULT_MARKDOWN_BEGIN===/===RESULT_MARKDOWN_END=== block.
Environment notes:
- Run Python via the project venv:
& "C:\Users\piesamso1\OneDrive - Publicis Groupe\Bureau\Lyréco\venv\Scripts\python.exe". - Oracle needs the correct TNS path (the
.envvalue is stale): set$env:ORACLE_TNS_ADMIN = "C:\Users\piesamso1\OneDrive - Publicis Groupe\Bureau\Lyréco\oracle"before any Oracle pull. Requires GlobalProtect VPN connected. wf_tables live in PostgreSQL viadatabase.crm_db.CRMDatabase;ga4_in TimescaleDB viadatabase.timeseries_db.TimeseriesDatabase.initiative_numberis TEXT in the live DB (e.g."2.2 · Pilot").
---
File Structure
| File | Responsibility | Action |
|------|----------------|--------|
| database/init_timescale_ga4.sql | ga4_product_events DDL | Modify — add 2 columns (fresh-install parity) |
| workers/oracle_ga4_products_pull.py | Oracle→Timescale product pull | Modify — select + upsert the 2 new columns |
| scripts/update_wf_test22_delivery_eta.py | Funnel + badge analysis, KPI upsert, markdown | Create |
---
Task 1: Add badge columns to the product-event warehouse + ingestion
Files:
- Modify:
database/init_timescale_ga4.sql(ga4_product_events DDL, around line 228page_type) - Modify:
workers/oracle_ga4_products_pull.py(SELECT ~line 57-80, upsert ~line 108-130, tuple ~line 151-170)
- [ ] Step 1: Add columns to the DDL (fresh-install parity)
In database/init_timescale_ga4.sql, in the ga4_product_events CREATE TABLE, add two columns immediately after the page_type TEXT, line (line 228):
`sql
page_type TEXT,
product_delivery_time TEXT, -- GA4 dimension25 ("En stock livraison 24h" badge)
product_stock TEXT, -- sibling stock-state custom dimension
last_pulled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
`
- [ ] Step 2: ALTER the live Timescale table (idempotent)
Run:
`powershell
& "C:\Users\piesamso1\OneDrive - Publicis Groupe\Bureau\Lyréco\venv\Scripts\python.exe" -c "from database.timeseries_db import TimeseriesDatabase; ts=TimeseriesDatabase(); ts.execute('ALTER TABLE ga4_product_events ADD COLUMN IF NOT EXISTS product_delivery_time TEXT, ADD COLUMN IF NOT EXISTS product_stock TEXT'); print('altered')"
`
Expected: prints altered.
- [ ] Step 3: Add the columns to the ingestion SELECT
In workers/oracle_ga4_products_pull.py, in PRODUCT_SQL_TEMPLATE (ends at PAGE_TYPE ~line 75), change the tail of the column list:
`sql
PRODUCT_BMSM_FLAG, PRODUCT_BMSM_TIER, PRODUCT_COUPON_CODE,
PAGE_TYPE,
PRODUCT_DELIVERY_TIME, PRODUCT_STOCK
FROM {schema}.T_ECOM_GA4_PRODUCT
`
- [ ] Step 4: Add the columns to the upsert INSERT list + ON CONFLICT set
In the upsert_sql string (~line 108-130), add the two columns to the INSERT column list (after page_type) and to the DO UPDATE SET so re-pulls populate them on existing rows:
`python
upsert_sql = (
"INSERT INTO ga4_product_events ("
"event_date, product_row_id, source_country, "
"event_unique_id, visit_unique_id, visitor_id, "
"event_name, event_timestamp, "
"product_reference, product_name, product_brand, product_variant, "
"web_category1, web_category2, web_category3, web_category4, "
"product_quantity, local_product_price, local_sales_amount, local_refund_amount, "
"currency_code, product_category_id, "
"product_list_name, product_list_id, product_list_index, "
"product_recommendation_program, sponsored_product, "
"product_promotion_name, product_promotion_id, "
"product_creative_name, product_creative_slot, "
"product_deal_type, product_bmsm_flag, product_bmsm_tier, product_coupon_code, "
"page_type, product_delivery_time, product_stock"
") VALUES %s "
"ON CONFLICT (event_date, event_unique_id, product_row_id, source_country) DO UPDATE SET "
" product_quantity = EXCLUDED.product_quantity, "
" local_product_price = EXCLUDED.local_product_price, "
" local_sales_amount = EXCLUDED.local_sales_amount, "
" product_recommendation_program = EXCLUDED.product_recommendation_program, "
" product_delivery_time = EXCLUDED.product_delivery_time, "
" product_stock = EXCLUDED.product_stock, "
" last_pulled_at = NOW()"
)
`
- [ ] Step 5: Add the two values to the row tuple
In the batch.append((...)) (~line 151-170), append the two new values at the end of the tuple, immediately after r["PAGE_TYPE"],:
`python
r["PAGE_TYPE"],
r["PRODUCT_DELIVERY_TIME"], r["PRODUCT_STOCK"],
))
`
- [ ] Step 6: Backfill FR over the post window (badge didn't exist before 05-13)
--days N filters EVENT_DATE >= TRUNC(SYSDATE) - N. With today = 2026-05-26, --days 16 covers from 2026-05-10 (a couple of days of margin before the 05-13 cutoff is fine and lets us confirm the badge is absent pre-cutoff). FR only.
Run (VPN must be up):
`powershell
$env:ORACLE_TNS_ADMIN = "C:\Users\piesamso1\OneDrive - Publicis Groupe\Bureau\Lyréco\oracle"
& "C:\Users\piesamso1\OneDrive - Publicis Groupe\Bureau\Lyréco\venv\Scripts\python.exe" -m workers.oracle_ga4_products_pull --schemas FRANCE --days 16
`
Expected: log lines ending with FRANCE: and DONE schemas=FRANCE. Non-trivial N (hundreds of thousands+).
- [ ] Step 7: Verify the new columns are now populated in the warehouse
Run:
`powershell
& "C:\Users\piesamso1\OneDrive - Publicis Groupe\Bureau\Lyréco\venv\Scripts\python.exe" -c "from database.timeseries_db import TimeseriesDatabase; ts=TimeseriesDatabase(); print(ts.query(\"SELECT COUNT(*) n, COUNT(product_delivery_time) nd FROM ga4_product_events WHERE source_country='FR' AND event_name='view_item' AND page_type='product' AND event_date BETWEEN '2026-05-13' AND '2026-05-21'\"))"
`
Expected: n > 0 and nd > 0 (delivery_time populated for a meaningful share of post-window PDP views).
- [ ] Step 8: Commit
`bash
git add database/init_timescale_ga4.sql workers/oracle_ga4_products_pull.py
git commit -m "feat: ingest GA4 product_delivery_time + product_stock (dimension25)
Co-Authored-By: Claude Opus 4.7 `
---
Task 2: Confirm the badge string value + first-appearance date
This determines the BADGE_LIKE predicate the analysis uses. Query our own warehouse (fast, indexed by event_date chunk) instead of the 113M-row Oracle table.
Files: none (verification only).
- [ ] Step 1: List distinct delivery-time values on FR PDP views, post window
Run:
`powershell
& "C:\Users\piesamso1\OneDrive - Publicis Groupe\Bureau\Lyréco\venv\Scripts\python.exe" -c "from database.timeseries_db import TimeseriesDatabase; ts=TimeseriesDatabase(); rows=ts.query(\"SELECT product_delivery_time v, COUNT(*) n FROM ga4_product_events WHERE source_country='FR' AND event_name='view_item' AND page_type='product' AND event_date BETWEEN '2026-05-13' AND '2026-05-21' GROUP BY product_delivery_time ORDER BY n DESC LIMIT 20\"); [print(repr(r['v']), r['n']) for r in rows]"
`
Expected: one of the values is the "En stock livraison 24h" badge (exact casing/wording visible). Note the literal.
- [ ] Step 2: Confirm the badge is absent before the cutoff
Run:
`powershell
& "C:\Users\piesamso1\OneDrive - Publicis Groupe\Bureau\Lyréco\venv\Scripts\python.exe" -c "from database.timeseries_db import TimeseriesDatabase; ts=TimeseriesDatabase(); rows=ts.query(\"SELECT event_date, COUNT(*) n FROM ga4_product_events WHERE source_country='FR' AND event_name='view_item' AND page_type='product' AND product_delivery_time ILIKE '%24h%' AND event_date BETWEEN '2026-05-10' AND '2026-05-21' GROUP BY event_date ORDER BY event_date\"); [print(r['event_date'], r['n']) for r in rows]"
`
Expected: near-zero rows before 2026-05-13, then non-trivial daily counts from 2026-05-13 onward. This is the functional confirmation that the badge went live on the stated date.
- [ ] Step 3: Decide
BADGE_LIKE
Default predicate for Task 3 is product_delivery_time ILIKE '%24h%'. If Step 1 shows the literal is cleanly 'En stock livraison 24h' with no unrelated 24h values, you may instead set BADGE_LIKE = 'En stock livraison 24h' (exact, no wildcards) for precision. Record the chosen value to use in Task 3 Step 1. No commit (verification only).
---
Task 3: Write the analysis script
Files:
- Create:
scripts/update_wf_test22_delivery_eta.py
- [ ] Step 1: Write the full script
Create scripts/update_wf_test22_delivery_eta.py with this exact content. (If Task 2 Step 3 chose an exact literal, change BADGE_LIKE accordingly — it is the only value that may need adjustment.)
`python
"""Initiative 2.2 — "En stock livraison 24h" PDP badge: baseline vs post tracking.
Computes two session-grain funnels on ALL FR traffic for two windows (baseline = 3 months pre go-live; post = go-live..cutoff), plus the badge functional check + saw-badge-vs-not split using the dimension25 signal (ga4_product_events.product_delivery_time). Writes wf_test_kpis rows for '2.2 · Pilot' (FR) and emits a result_markdown block for the dashboard.
Idempotent / re-runnable. Mirrors scripts/update_wf_test5_variants.py.
Run: python scripts/update_wf_test22_delivery_eta.py """ from __future__ import annotations
import datetime as dt import sys from pathlib import Path
_REPO = Path(__file__).resolve().parent.parent if str(_REPO) not in sys.path: sys.path.insert(0, str(_REPO))
from database.crm_db import CRMDatabase from database.timeseries_db import TimeseriesDatabase
INITIATIVE = "2.2 · Pilot" SOURCE_COUNTRY = "FR"
# Go-live of "En stock livraison 24h" (user-confirmed). Baseline = 3 full # months before (team rule 2026-05-19); post = go-live .. analysis cutoff. CUTOFF = dt.date(2026, 5, 13) BASELINE_START = dt.date(2026, 2, 13) BASELINE_END = dt.date(2026, 5, 12) POST_START = dt.date(2026, 5, 13) POST_END = dt.date(2026, 5, 29)
# add_to_cart page_type contexts that represent a product tile in a listing # (confirmed present on FR add_to_cart events, probe 2026-05-26). PDP = 'product'. VIGNETTE_PAGE_TYPES = [ "search-results", "product-category", "product-list", "category", "search_with_results", "list", ]
# Badge match predicate value (see plan Task 2). Robust substring by default. BADGE_LIKE = "%24h%"
def pct(num: int, den: int) -> float: """Rate in [0,1]; 0 when denominator is 0.""" return (num / den) if den else 0.0
def funnel(ts: TimeseriesDatabase, start: dt.date, end: dt.date) -> dict: """Session-grain PDP + vignette funnel counts for [start, end] (FR).
Stage A = saw PDP (view_item@product) / saw vignette (select_item@listing) Stage B = + add_to_cart from that surface Stage C = + purchased in the same window's sessions """ rows = ts.query( """ WITH ev AS ( SELECT visit_unique_id, bool_or(event_name='view_item' AND page_type='product') AS pdp_view, bool_or(event_name='add_to_cart' AND page_type='product') AS pdp_atc, bool_or(event_name='select_item' AND page_type = ANY(%(vig)s)) AS vig_view, bool_or(event_name='add_to_cart' AND page_type = ANY(%(vig)s)) AS vig_atc FROM ga4_action_events WHERE source_country='FR' AND event_date BETWEEN %(s)s AND %(e)s AND visit_unique_id IS NOT NULL GROUP BY visit_unique_id ), buy AS ( SELECT DISTINCT visit_unique_id FROM ga4_sessions WHERE source_country='FR' AND session_date BETWEEN %(s)s AND %(e)s AND order_count > 0 ) SELECT COUNT(*) FILTER (WHERE pdp_view) AS pdp_a, COUNT(*) FILTER (WHERE pdp_view AND pdp_atc) AS pdp_b, COUNT(*) FILTER (WHERE pdp_view AND pdp_atc AND b.visit_unique_id IS NOT NULL) AS pdp_c, COUNT(*) FILTER (WHERE vig_view) AS vig_a, COUNT(*) FILTER (WHERE vig_view AND vig_atc) AS vig_b, COUNT(*) FILTER (WHERE vig_view AND vig_atc AND b.visit_unique_id IS NOT NULL) AS vig_c FROM ev LEFT JOIN buy b USING (visit_unique_id) """, {"vig": VIGNETTE_PAGE_TYPES, "s": start, "e": end}, ) r = {k: int(v or 0) for k, v in rows[0].items()} return r
def badge_split(ts: TimeseriesDatabase, start: dt.date, end: dt.date) -> list[dict]: """Post-window PDP sessions split by whether the PDP view carried the badge.
Returns up to 2 rows: saw_badge in {True, False} with pdp/atc/buy session counts. PDP-view population comes from ga4_product_events (item-scoped, where the badge lives); add_to_cart + purchase come from action/session tables. """ rows = ts.query( """ WITH pdp_badge AS ( SELECT visit_unique_id, bool_or(product_delivery_time ILIKE %(badge)s) AS saw_badge FROM ga4_product_events WHERE source_country='FR' AND event_date BETWEEN %(s)s AND %(e)s AND event_name='view_item' AND page_type='product' AND visit_unique_id IS NOT NULL GROUP BY visit_unique_id ), ev AS ( SELECT visit_unique_id, bool_or(event_name='add_to_cart' AND page_type='product') AS pdp_atc FROM ga4_action_events WHERE source_country='FR' AND event_date BETWEEN %(s)s AND %(e)s AND visit_unique_id IS NOT NULL GROUP BY visit_unique_id ), buy AS ( SELECT DISTINCT visit_unique_id FROM ga4_sessions WHERE source_country='FR' AND session_date BETWEEN %(s)s AND %(e)s AND order_count > 0 ) SELECT COALESCE(p.saw_badge, false) AS saw_badge, COUNT(*) AS pdp_sessions, COUNT(*) FILTER (WHERE e.pdp_atc) AS atc_sessions, COUNT(*) FILTER (WHERE e.pdp_atc AND b.visit_unique_id IS NOT NULL) AS buy_sessions FROM pdp_badge p LEFT JOIN ev e USING (visit_unique_id) LEFT JOIN buy b USING (visit_unique_id) GROUP BY COALESCE(p.saw_badge, false) """, {"badge": BADGE_LIKE, "s": start, "e": end}, ) return [{k: (bool(v) if k == "saw_badge" else int(v or 0)) for k, v in r.items()} for r in rows]
def upsert_kpi(crm: CRMDatabase, kpi_index: int, kpi_name: str, expected: str, baseline_value, baseline_text, actual_value, actual_text) -> None: """Insert-or-update one wf_test_kpis row for the 2.2 pilot.""" crm.execute( """ INSERT INTO wf_test_kpis ( initiative_number, source_country, kpi_index, kpi_name, expected_result, baseline_value, baseline_text, actual_value, actual_result_text, last_computed_at, last_ingested_at ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW(),NOW()) ON CONFLICT (initiative_number, source_country, kpi_index) DO UPDATE SET kpi_name = EXCLUDED.kpi_name, expected_result = EXCLUDED.expected_result, baseline_value = EXCLUDED.baseline_value, baseline_text = EXCLUDED.baseline_text, actual_value = EXCLUDED.actual_value, actual_result_text = EXCLUDED.actual_result_text, last_computed_at = NOW(), last_ingested_at = NOW() """, (INITIATIVE, SOURCE_COUNTRY, kpi_index, kpi_name, expected, baseline_value, baseline_text, actual_value, actual_text), )
def main() -> int: crm = CRMDatabase() ts = TimeseriesDatabase()
print("1. Computing baseline funnel (FR, %s → %s)…" % (BASELINE_START, BASELINE_END)) bl = funnel(ts, BASELINE_START, BASELINE_END) print(" PDP A=%(pdp_a)d B=%(pdp_b)d C=%(pdp_c)d" % bl) print(" VIGN A=%(vig_a)d B=%(vig_b)d C=%(vig_c)d" % bl)
print("2. Computing post funnel (FR, %s → %s)…" % (POST_START, POST_END)) po = funnel(ts, POST_START, POST_END) print(" PDP A=%(pdp_a)d B=%(pdp_b)d C=%(pdp_c)d" % po) print(" VIGN A=%(vig_a)d B=%(vig_b)d C=%(vig_c)d" % po)
print("3. Computing badge split (post)…") split = badge_split(ts, POST_START, POST_END) by = {r["saw_badge"]: r for r in split} saw = by.get(True, {"pdp_sessions": 0, "atc_sessions": 0, "buy_sessions": 0}) nob = by.get(False, {"pdp_sessions": 0, "atc_sessions": 0, "buy_sessions": 0}) total_pdp = saw["pdp_sessions"] + nob["pdp_sessions"] coverage = pct(saw["pdp_sessions"], total_pdp) print(" saw-badge PDP sessions=%d no-badge=%d coverage=%.1f%%" % (saw["pdp_sessions"], nob["pdp_sessions"], 100 * coverage))
# ---- rates (0..1) ---- bl_pdp_ab, po_pdp_ab = pct(bl["pdp_b"], bl["pdp_a"]), pct(po["pdp_b"], po["pdp_a"]) bl_pdp_bc, po_pdp_bc = pct(bl["pdp_c"], bl["pdp_b"]), pct(po["pdp_c"], po["pdp_b"]) bl_vig_ab, po_vig_ab = pct(bl["vig_b"], bl["vig_a"]), pct(po["vig_b"], po["vig_a"]) bl_vig_bc, po_vig_bc = pct(bl["vig_c"], bl["vig_b"]), pct(po["vig_c"], po["vig_b"])
def win(s, e): # human window text return "%s → %s" % (s, e)
bl_txt = "Baseline %s (FR, all traffic)" % win(BASELINE_START, BASELINE_END) po_txt = "Post %s (FR, all traffic; refresh as data lands)" % win(POST_START, POST_END)
print("4. Fixing 2.2 · Pilot start_date → %s…" % POST_START) crm.execute( "UPDATE wf_tests SET start_date=%s, last_ingested_at=NOW() " "WHERE initiative_number=%s AND source_country=%s", (POST_START, INITIATIVE, SOURCE_COUNTRY), )
print("5. Upserting wf_test_kpis rows…") upsert_kpi(crm, 1, "Conversion rate PDP to cart", "Improve conversion rate", bl_pdp_ab, "%s: %d PDP-view sessions, %d added (%.2f%%)" % (bl_txt, bl["pdp_a"], bl["pdp_b"], 100 * bl_pdp_ab), po_pdp_ab, "%s: %d PDP-view sessions, %d added (%.2f%%)" % (po_txt, po["pdp_a"], po["pdp_b"], 100 * po_pdp_ab)) upsert_kpi(crm, 2, "PDP cart → conversion", "Improve conversion rate", bl_pdp_bc, "%s: %d add-to-cart sessions, %d purchased (%.2f%%)" % (bl_txt, bl["pdp_b"], bl["pdp_c"], 100 * bl_pdp_bc), po_pdp_bc, "%s: %d add-to-cart sessions, %d purchased (%.2f%%)" % (po_txt, po["pdp_b"], po["pdp_c"], 100 * po_pdp_bc)) upsert_kpi(crm, 3, "Vignette → add to cart", "Improve conversion rate", bl_vig_ab, "%s: %d vignette-interaction sessions, %d added (%.2f%%)" % (bl_txt, bl["vig_a"], bl["vig_b"], 100 * bl_vig_ab), po_vig_ab, "%s: %d vignette-interaction sessions, %d added (%.2f%%)" % (po_txt, po["vig_a"], po["vig_b"], 100 * po_vig_ab)) upsert_kpi(crm, 4, "Vignette cart → conversion", "Improve conversion rate", bl_vig_bc, "%s: %d add-to-cart sessions, %d purchased (%.2f%%)" % (bl_txt, bl["vig_b"], bl["vig_c"], 100 * bl_vig_bc), po_vig_bc, "%s: %d add-to-cart sessions, %d purchased (%.2f%%)" % (po_txt, po["vig_b"], po["vig_c"], 100 * po_vig_bc)) upsert_kpi(crm, 5, "Badge PDP coverage (post)", "Badge fires on PDP views", None, "n/a (badge did not exist before %s)" % POST_START, coverage, "Post %s: %d of %d PDP-view sessions saw the badge (%.1f%%)" % (win(POST_START, POST_END), saw["pdp_sessions"], total_pdp, 100 * coverage))
# ---- result markdown ---- def rate(n, d): return "%.2f%%" % (100 * pct(n, d))
def dpp(b, p): # delta in percentage points d = round(100 * (p - b), 2) return ("+%.2f" % d) if d > 0 else ("%.2f" % d)
saw_ab = pct(saw["atc_sessions"], saw["pdp_sessions"]) nob_ab = pct(nob["atc_sessions"], nob["pdp_sessions"]) saw_ac = pct(saw["buy_sessions"], saw["pdp_sessions"]) nob_ac = pct(nob["buy_sessions"], nob["pdp_sessions"]) today = dt.date.today().isoformat()
body_md = f"""## Initiative 2.2 — "En stock livraison 24h" PDP badge (FR)
Data refreshed: {today} · script: scripts/update_wf_test22_delivery_eta.py
Baseline: {BASELINE_START} → {BASELINE_END} · Post (badge live): {POST_START} → {POST_END}
All FR traffic, session grain, same-session purchase.
Funnel — baseline vs post
| Path | Stage | Baseline | Post | Δ (pp) | |---|---|---:|---:|---:| | PDP | view → add-to-cart | {rate(bl['pdp_b'], bl['pdp_a'])} | {rate(po['pdp_b'], po['pdp_a'])} | {dpp(bl_pdp_ab, po_pdp_ab)} | | PDP | add-to-cart → purchase | {rate(bl['pdp_c'], bl['pdp_b'])} | {rate(po['pdp_c'], po['pdp_b'])} | {dpp(bl_pdp_bc, po_pdp_bc)} | | PDP | view → purchase (overall) | {rate(bl['pdp_c'], bl['pdp_a'])} | {rate(po['pdp_c'], po['pdp_a'])} | {dpp(pct(bl['pdp_c'], bl['pdp_a']), pct(po['pdp_c'], po['pdp_a']))} | | Vignette | interaction → add-to-cart | {rate(bl['vig_b'], bl['vig_a'])} | {rate(po['vig_b'], po['vig_a'])} | {dpp(bl_vig_ab, po_vig_ab)} | | Vignette | add-to-cart → purchase | {rate(bl['vig_c'], bl['vig_b'])} | {rate(po['vig_c'], po['vig_b'])} | {dpp(bl_vig_bc, po_vig_bc)} | | Vignette | interaction → purchase (overall) | {rate(bl['vig_c'], bl['vig_a'])} | {rate(po['vig_c'], po['vig_a'])} | {dpp(pct(bl['vig_c'], bl['vig_a']), pct(po['vig_c'], po['vig_a']))} |
> Baseline n: PDP-view sessions {bl['pdp_a']:,}, vignette sessions {bl['vig_a']:,}.
> Post n: PDP-view sessions {po['pdp_a']:,}, vignette sessions {po['vig_a']:,}.
> Vignette Stage-A uses select_item (tile click) as the interaction proxy
> (view_item_list is not ingested) — read as "of sessions that clicked a tile…".
Badge functional check + saw-badge vs not (post window)
Badge PDP coverage: {rate(saw['pdp_sessions'], total_pdp)} of post PDP-view sessions
carried dimension25 ("En stock livraison 24h") — match product_delivery_time ILIKE '{BADGE_LIKE}'.
| Cohort (post) | PDP-view sess | view→cart | view→purchase | |---|---:|---:|---:| | Saw badge | {saw['pdp_sessions']:,} | {rate(saw['atc_sessions'], saw['pdp_sessions'])} | {rate(saw['buy_sessions'], saw['pdp_sessions'])} | | Did not see badge | {nob['pdp_sessions']:,} | {rate(nob['atc_sessions'], nob['pdp_sessions'])} | {rate(nob['buy_sessions'], nob['pdp_sessions'])} | | Δ (saw − not, pp) | — | {dpp(nob_ab, saw_ab)} | {dpp(nob_ac, saw_ac)} |
> Coverage > 0 from {POST_START} confirms the badge is firing on PDPs. > The saw-vs-not split isolates the badge effect from seasonality better than raw pre/post.
Next action
- Re-run
scripts/update_wf_test22_delivery_eta.py(or the dashboard refresh button) as
print("\n===RESULT_MARKDOWN_BEGIN===") print(body_md) print("===RESULT_MARKDOWN_END===") return 0
if __name__ == "__main__":
sys.exit(main())
`
- [ ] Step 2: Byte-compile to catch syntax errors
Run:
`powershell
& "C:\Users\piesamso1\OneDrive - Publicis Groupe\Bureau\Lyréco\venv\Scripts\python.exe" -m py_compile scripts/update_wf_test22_delivery_eta.py; if ($?) { "compile OK" }
`
Expected: compile OK, no traceback.
---
Task 4: Run the analysis end-to-end and verify outputs
Files: none (execution + verification).
- [ ] Step 1: Run the script
Run:
`powershell
& "C:\Users\piesamso1\OneDrive - Publicis Groupe\Bureau\Lyréco\venv\Scripts\python.exe" scripts/update_wf_test22_delivery_eta.py
`
Expected: prints steps 1–5, then the ===RESULT_MARKDOWN_BEGIN===…===RESULT_MARKDOWN_END=== block. No traceback.
- [ ] Step 2: Sanity-check the funnel numbers
Inspect the printed counts. Expected sanity properties:
pdp_a ≥ pdp_b ≥ pdp_candvig_a ≥ vig_b ≥ vig_c(stages are nested) in both windows.- Rates are within (0%, 100%) — not 0 and not 100.
- Baseline
pdp_a(3 months) is much larger than postpdp_a(~2 weeks).
- [ ] Step 3: Verify KPI rows persisted with baseline + actual
Run:
`powershell
& "C:\Users\piesamso1\OneDrive - Publicis Groupe\Bureau\Lyréco\venv\Scripts\python.exe" -c "from database.crm_db import CRMDatabase; c=CRMDatabase(); rows=c.query(\"SELECT kpi_index,kpi_name,baseline_value,actual_value FROM wf_test_kpis WHERE initiative_number='2.2 · Pilot' AND source_country='FR' ORDER BY kpi_index\"); [print(r) for r in rows]"
`
Expected: 5 rows. KPIs 1–4 have non-null baseline_value and actual_value; KPI 5 has null baseline and non-null actual (coverage).
- [ ] Step 4: Verify the start_date fix
Run:
`powershell
& "C:\Users\piesamso1\OneDrive - Publicis Groupe\Bureau\Lyréco\venv\Scripts\python.exe" -c "from database.crm_db import CRMDatabase; c=CRMDatabase(); print(c.query(\"SELECT initiative_number,start_date FROM wf_tests WHERE initiative_number='2.2 · Pilot' AND source_country='FR'\"))"
`
Expected: start_date = 2026-05-13.
- [ ] Step 5: Verify idempotency (re-run changes nothing structural)
Re-run the script once more (Step 1 command). Expected: completes cleanly; Step 3 still shows exactly 5 KPI rows (values may refresh, no duplicates).
---
Task 5: Verify dashboard render, then commit
Files: none (verification + commit).
- [ ] Step 1: Confirm the backlog panel renders the 2.2 KPIs
The dashboard service LeadContagionDashboard already serves /panel/wf-backlog. Fetch the FR panel and confirm the 2.2 KPI names + values appear (Basic Auth from .dashboard_extras.env; substitute a valid user/password):
`powershell
& "C:\Users\piesamso1\OneDrive - Publicis Groupe\Bureau\Lyréco\venv\Scripts\python.exe" -c "import urllib.request,base64; u='http://127.0.0.1:8001/panel/wf-backlog?country=FR'; r=urllib.request.Request(u); r.add_header('Authorization','Basic '+base64.b64encode(b'lyreco:REPLACE_WITH_PASSWORD').decode()); h=urllib.request.urlopen(r).read().decode('utf-8','replace'); print('Badge PDP coverage' in h, 'Vignette → add to cart' in h)"
`
Expected: True True (both new KPI names present in the rendered HTML). If the dashboard isn't running or auth differs, instead confirm via the DB check in Task 4 Step 3 and note the panel reads the same table.
- [ ] Step 2: Commit the analysis script
`bash
git add scripts/update_wf_test22_delivery_eta.py
git commit -m "feat: initiative 2.2 delivery-ETA baseline + post funnel tracking
PDP and vignette view->add-to-cart->purchase funnels (FR, session grain) for baseline (2026-02-13..05-12) vs post (2026-05-13..05-29), plus dimension25 badge coverage + saw-badge-vs-not split. Writes wf_test_kpis for 2.2 Pilot and emits result_markdown for the backlog panel.
Co-Authored-By: Claude Opus 4.7 `
- [ ] Step 3: Confirm working tree is clean of scratch files
Run:
`bash
git status --short
`
Expected: no scripts/_probe_.py present (deleted during design); only intended changes committed. If any _probe_.py reappeared, delete them.
---
Self-Review (completed by plan author)
Spec coverage:
- Windows (baseline/post, cutoff 05-13) → Task 3 constants, Task 4 verification. ✓
- PDP + vignette funnels, session grain, page_type split → Task 3
funnel(). ✓ - Vignette
select_itemdenominator + caveat → Task 3funnel()+ markdown note. ✓ dimension25 = PRODUCT_DELIVERY_TIMEingestion (schema + pull + FR post backfill) → Task 1. ✓- Functional check + saw-badge-vs-not split → Task 3
badge_split()+ markdown. ✓ - 5 KPI rows (keep #1, add #2–#5) with baseline/actual → Task 3
upsert_kpi(), Task 4 Step 3. ✓ - start_date fix 05-20 → 05-13 → Task 3 main(), Task 4 Step 4. ✓
- result_markdown for backlog panel, no new endpoint → Task 3 + Task 5 Step 1. ✓
- Cleanup of probe scripts → done during design; re-checked Task 5 Step 3. ✓
Placeholder scan: BADGE_LIKE is a concrete default (%24h%) with an explicit Task 2 confirmation step; REPLACE_WITH_PASSWORD is an intentional credential the operator substitutes. No TODO/TBD/"handle edge cases". ✓
Type/name consistency: funnel() returns dict keys pdp_a/b/c, vig_a/b/c used consistently; badge_split() returns saw_badge/pdp_sessions/atc_sessions/buy_sessions used consistently; upsert_kpi() signature matches all 5 call sites; pct() used throughout. ✓