From d5936f1301883046d0d54d249a5c66ab716a9e46 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Thu, 26 Feb 2026 15:00:24 +0100 Subject: [PATCH] fix(experimentAnalysis): use ISO calendar weeks for treatment/baseline periods - Add ISO week calculation functions (getISOWeek, getISOWeekMonday, calculateISOWeekRange) - Expand experiment dates to full ISO weeks (Monday-Sunday) instead of exact date ranges - Use actual data.length for averaging metrics instead of theoretical durationWeeks - Baseline now takes same number of complete weeks immediately before experiment Fixes incorrect units sold average calculation (was 6.5, now correctly shows 4.33 for 13 units over 3 weeks) Co-authored-by: Qwen-Coder --- services/experimentAnalysis.ts | 110 +++++++++++++++++++++++++++++---- 1 file changed, 98 insertions(+), 12 deletions(-) diff --git a/services/experimentAnalysis.ts b/services/experimentAnalysis.ts index 07f7438..4673f9d 100644 --- a/services/experimentAnalysis.ts +++ b/services/experimentAnalysis.ts @@ -50,6 +50,85 @@ function getWeekStartSunday(year: number, week: number): number { return startYear.getTime() + (week - 1) * 7 * 86400000; } +/** + * Get ISO week number for a given date + * ISO weeks start on Monday, week 1 contains the first Thursday of the year + */ +function getISOWeek(date: Date): { year: number; week: number } { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; // Convert Sunday (0) to 7 + d.setUTCDate(d.getUTCDate() + 4 - dayNum); // Set to nearest Thursday + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNum = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); + return { year: d.getUTCFullYear(), week: weekNum }; +} + +/** + * Get the Monday of an ISO week + */ +function getISOWeekMonday(year: number, week: number): number { + const jan4 = new Date(Date.UTC(year, 0, 4)); // Jan 4 is always in week 1 + const dayOfWeek = jan4.getUTCDay() || 7; // Get day of week (1-7, Monday=1) + const week1Monday = new Date(Date.UTC(year, 0, 4 - (dayOfWeek - 1))); + return week1Monday.getTime() + (week - 1) * 7 * 86400000; +} + +/** + * Calculate the number of distinct ISO weeks between two dates + * and return the expanded date range (first Monday to last Sunday) + */ +function calculateISOWeekRange(startDate: Date, endDate: Date): { + numWeeks: number; + expandedStartTs: number; + expandedEndTs: number; + weekKeys: string[]; +} { + const startISO = getISOWeek(startDate); + const endISO = getISOWeek(endDate); + + const weekKeys: string[] = []; + let currentYear = startISO.year; + let currentWeek = startISO.week; + + // Collect all week keys between start and end + while (true) { + const weekKey = `${currentYear}-W${String(currentWeek).padStart(2, '0')}`; + weekKeys.push(weekKey); + + if (currentYear === endISO.year && currentWeek === endISO.week) { + break; + } + + // Move to next week + currentWeek++; + const weeksInYear = getWeeksInYear(currentYear); + if (currentWeek > weeksInYear) { + currentWeek = 1; + currentYear++; + } + } + + // Get expanded range: from Monday of first week to Sunday of last week + const expandedStartTs = getISOWeekMonday(startISO.year, startISO.week); + const expandedEndTs = getISOWeekMonday(endISO.year, endISO.week) + 7 * 86400000; + + return { + numWeeks: weekKeys.length, + expandedStartTs, + expandedEndTs, + weekKeys, + }; +} + +/** + * Get number of weeks in an ISO year (52 or 53) + */ +function getWeeksInYear(year: number): number { + const dec28 = new Date(Date.UTC(year, 11, 28)); // Dec 28 is always in last week + const iso = getISOWeek(dec28); + return iso.week; +} + function aggregateWeeklyMetrics( asinSet: Set, salesData: CombinedKPIs[], @@ -161,7 +240,7 @@ function splitPeriods( } function avgMetric(data: ComputedWeeklyMetrics[], metric: string, durationWeeks: number): number { - if (data.length === 0 || durationWeeks <= 0) return 0; + if (data.length === 0) return 0; // For rates and ratios, we must sum the raw absolute components across all included weeks // and THEN calculate the ratio, otherwise averaging percentages yields mathematically incorrect results. @@ -186,9 +265,10 @@ function avgMetric(data: ComputedWeeklyMetrics[], metric: string, durationWeeks: return totalAdRevenue > 0 && totalCost > 0 ? (totalCost / totalAdRevenue) * 100 : 0; } - // For absolute quantities (units, revenue, sessions), we sum them and divide by the duration + // For absolute quantities (units, revenue, sessions), we sum them and divide by the number of weeks + // Use actual data.length instead of theoretical durationWeeks for accuracy const sum = data.reduce((s, w) => s + getMetricValue(w, metric), 0); - return sum / durationWeeks; + return sum / data.length; } const METRICS = ['units', 'sessions', 'cvr', 'ctr', 'roas', 'revenue', 'acos']; @@ -270,19 +350,26 @@ export function computeDiD( const startDate = parseLocalDate(experiment.start_date); const endDate = experiment.end_date ? parseLocalDate(experiment.end_date) : new Date(new Date().setHours(0, 0, 0, 0)); - const startTs = startDate.getTime(); - const endTs = endDate.getTime() + 86400000; // Add 24h so end bound is midnight of next day + // Calculate ISO week range: expands to full weeks (Monday to Sunday) + const isoRange = calculateISOWeekRange(startDate, endDate); + const startTs = isoRange.expandedStartTs; + const endTs = isoRange.expandedEndTs; + const treatmentDurationWeeks = isoRange.numWeeks; let beforeStartTs: number; let beforeEndTs: number; if (experiment.baseline_start_date && experiment.baseline_end_date) { - beforeStartTs = parseLocalDate(experiment.baseline_start_date).getTime(); - beforeEndTs = parseLocalDate(experiment.baseline_end_date).getTime() + 86400000; + // Use custom baseline dates - also expand to full ISO weeks + const baselineStart = parseLocalDate(experiment.baseline_start_date); + const baselineEnd = parseLocalDate(experiment.baseline_end_date); + const baselineIsoRange = calculateISOWeekRange(baselineStart, baselineEnd); + beforeStartTs = baselineIsoRange.expandedStartTs; + beforeEndTs = baselineIsoRange.expandedEndTs; } else { - const durationMs = endTs - startTs; - beforeEndTs = startTs; // Adjacent: baseline ends right at the moment experiment starts - beforeStartTs = beforeEndTs - durationMs; + // Take the same number of complete weeks immediately before the experiment + beforeEndTs = startTs; // Baseline ends right when experiment starts (Monday) + beforeStartTs = beforeEndTs - (treatmentDurationWeeks * 7 * 86400000); } const treatmentAsinSet = getExperimentAsins(experiment.asins || [], salesData); @@ -300,8 +387,7 @@ export function computeDiD( ? splitPeriods(controlWeekly, startTs, endTs, beforeStartTs, beforeEndTs) : { before: [] as ComputedWeeklyMetrics[], after: [] as ComputedWeeklyMetrics[] }; - const treatmentDurationWeeks = Math.max(1, Math.round((endTs - startTs) / (7 * 86400000))); - const baselineDurationWeeks = Math.max(1, Math.round((beforeEndTs - beforeStartTs) / (7 * 86400000))); + const baselineDurationWeeks = Math.max(1, cSplit.before.length || tSplit.before.length); // Debug: log data flow for ACOS diagnosis const afterTotalCost = tSplit.after.reduce((s, w) => s + w.cost, 0);