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 <qwen-coder@alibabacloud.com>
This commit is contained in:
Christian Vidal Wolf
2026-02-26 15:00:24 +01:00
co-authored by Qwen-Coder
parent b840050686
commit d5936f1301
+98 -12
View File
@@ -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<string>,
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);