*/ public function read(StatRange $range): array { return self::of($range, $range->days() === 0 ? [] : $this->stats->inRange($range), $this->optins->interpretations()); } /** Resolve both windows with one interpretation snapshot, without visitor data. * @return array */ public function compare(StatRange $range): array { if ($range->days() === 0) return $this->read($range) + ['complete_days' => true]; $optins = $this->optins->interpretations(); $current = self::of($range, $this->stats->inRange($range), $optins); $previous = $range->previous(); $current['previous'] = self::of($previous, $this->stats->inRange($previous), $optins); $current['complete_days'] = true; $current['insights'] = Insights::forReport($current); return $current; } /** * The same screen, as arithmetic. * * `$optinRows` carries **every** Optin, soft-deleted ones included — which * is one half of the rule below and the reason * {@see OptinRepository::interpretations()} has no `WHERE` and no `LIMIT`. * * @param iterable> $statRows `(optin_id, stat_date, kind, count)`. * @param iterable> $optinRows Short reporting metadata, including publication history and parent id. * @return array */ public static function of(StatRange $range, iterable $statRows, iterable $optinRows): array { $optins = self::interpretable($optinRows); [$byGoal, $byOptin] = self::bucket($range, $statRows, $optins); $cards = []; foreach (Goal::cases() as $goal) { $held = array_filter($optins, static fn (InterpretedOptin $o): bool => $o->goal === $goal && ($o->wasPublished || isset($byOptin[$o->id]))); // A Goal no Optin holds has nothing to report. if ($held === []) { continue; } $cards[] = self::card($goal, $range, $byGoal[$goal->value] ?? [], $held, $byOptin); } // `days` travels so the screen can say which window is selected // without spelling {@see StatRange::DEFAULT_DAYS} a second time in a // bundle with nothing asserting the two agree. return ['from' => $range->from, 'to' => $range->to, 'days' => $range->days(), 'goals' => $cards, 'impact' => self::impact($cards)]; } /** Compatible actions only; send events never inflate captured Leads. * @param list> $cards * @return list> */ private static function impact(array $cards): array { $groups = [ 'leads' => ['label' => __('Submissions', 'wconvert'), 'note' => __('Form fills across all capture goals, including repeats', 'wconvert')], 'offers' => ['label' => __('Offer link clicks', 'wconvert'), 'note' => __('Clicks to linked offers or content', 'wconvert')], 'carts' => ['label' => __('Cart return clicks', 'wconvert'), 'note' => __('Clicks back to a shopping basket, not orders', 'wconvert')], 'additions' => ['label' => __('Basket additions', 'wconvert'), 'note' => __('Campaigns shown with a confirmed addition, not purchases', 'wconvert')], 'impressions' => ['label' => __('Shown', 'wconvert'), 'note' => __('Every time a campaign was shown, including repeats', 'wconvert')], ]; $impact = []; foreach ($groups as $id => $group) $impact[$id] = $group + ['id' => $id, 'count' => 0, 'goals' => []]; foreach ($cards as $card) { $goal = Goal::from($card['goal']); $action = $goal->outcome()->action; // A message converts on nothing; it adds its Impressions and no result (ADR 0163). if (!$goal->outcome()->countsNothing()) { $key = $action === 'add_to_cart' ? 'additions' : ($action === 'submit' ? 'leads' : ($goal === Goal::RecoverCart ? 'carts' : 'offers')); $impact[$key]['count'] += $card['conversions']; $impact[$key]['goals'][] = $card['goal']; } $impact['impressions']['count'] += $card['impressions']; $impact['impressions']['goals'][] = $card['goal']; } return array_values($impact); } /** * One Goal's card, with its own Optins inside it. * * Goal publication requirements make the named action consistent across * this card. Measurement copy states the limit of the evidence (ADR 0085). * * @param list> $rows * @param array $held * @param array>> $byOptin * @return array */ private static function card(Goal $goal, StatRange $range, array $rows, array $held, array $byOptin): array { return [ 'goal' => $goal->value, 'label' => $goal->label(), 'headline_label' => $goal->headlineLabel(), 'measurement' => $goal->outcome()->measurement, 'proof_level' => $goal->outcome()->proofLevel, 'action' => $goal->outcome()->action, 'result_label' => $goal === Goal::DeliverLeadMagnet ? __('Resource requests', 'wconvert') : $goal->headlineLabel(), 'rate_label' => $goal->rateLabel(), 'undelivered_conversions' => self::undeliveredConversions($goal, $rows), ...self::numbers($goal, $range, $rows), 'optins' => self::optinRows($goal, $range, $held, $byOptin), ]; } /** * Same-period event difference, not a per-Lead pending or failed population. * Analytics presents both operands separately instead (ADR 0089). * Delayed or repeated sends can offset unrelated requests in the window. * * @param list> $rows */ private static function undeliveredConversions(Goal $goal, array $rows): ?int { if ($goal->headlineKind() !== StatKind::LeadMagnetDelivered) { return null; } $totals = GoalReport::totals($rows); return max( 0, ($totals[StatKind::Conversion->value] ?? 0) - ($totals[StatKind::LeadMagnetDelivered->value] ?? 0) ); } /** * The numbers a card and an Optin row both carry, over one set of rows. * * **Conversion rate is conversions ÷ impressions** — the Conversion kind * and not the headline kind, so a lead-magnet Optin's rate measures what * visitors did rather than what the delivery job managed afterwards. An * [[Impression]] is one Optin appearing to one visitor and a [[Dismissal]] * is a deliberate close; neither moves when the Goal does. * * `null` rather than zero where nothing was shown: a rate over no * denominator is undefined, and 0% is a claim that visitors saw it and did * not act. * * @param list> $rows * @return array */ private static function numbers(Goal $goal, StatRange $range, array $rows): array { $totals = GoalReport::totals($rows); $impressions = $totals[StatKind::Impression->value] ?? 0; $conversions = $totals[StatKind::Conversion->value] ?? 0; return [ 'headline' => $totals[$goal->headlineKind()->value] ?? 0, 'conversions' => $conversions, 'items_added' => $totals[StatKind::CartAddition->value] ?? 0, 'deliveries' => $goal->headlineKind() === StatKind::LeadMagnetDelivered ? ($totals[StatKind::LeadMagnetDelivered->value] ?? 0) : null, 'impressions' => $impressions, 'dismissals' => $totals[StatKind::Dismiss->value] ?? 0, 'conversion_rate' => $impressions === 0 || $goal->outcome()->countsNothing() ? null : round($conversions / $impressions, 4), 'by_day' => self::series($goal, $range, $rows), 'conversion_by_day' => self::kindSeries(StatKind::Conversion, $range, $rows), 'impression_by_day' => self::kindSeries(StatKind::Impression, $range, $rows), ]; } /** @param list> $rows * @return array */ private static function kindSeries(StatKind $kind, StatRange $range, array $rows): array { $days = array_fill_keys($range->eachDay(), 0); foreach ($rows as $row) { if (($row['kind'] ?? null) === $kind->value) $days[(string) $row['stat_date']] += (int) $row['count']; } return $days; } /** * The headline number per day, over every day in the window. * * Seeded from the range rather than from the rows, so a day nothing * happened on reports 0 rather than being absent — the quiet days are * exactly the ones a merchant is reading for. * * @param list> $rows * @return array */ private static function series(Goal $goal, StatRange $range, array $rows): array { $days = array_fill_keys($range->eachDay(), 0); foreach (GoalReport::byDay($goal, $rows) as $day => $count) { $days[$day] = $count; } return $days; } /** * The per-Optin report includes inspectable historical rows (ADR 0089). * Soft-deleted counts and rows remain; publication actions do not. * * Each row carries its own daily series, which is what "comparison is * offered within an Optin over time" means — otherwise the only way to * compare an Optin with itself would be to move the whole screen's window * and remember the last number. * * Newest first, off the ULID — the id already sorts chronologically, so * this is the Optin list's own order and not a ranking by any number on * the row. * * @param array $held * @param array>> $byOptin * @return list> */ private static function optinRows(Goal $goal, StatRange $range, array $held, array $byOptin): array { $ordered = $held; // SORT_STRING, because a ULID is base32 text: under SORT_REGULAR an // all-digit one would be compared as a number against its neighbours. krsort($ordered, SORT_STRING); $rows = []; foreach ($ordered as $id => $optin) { $rows[] = [ 'id' => $optin->id, 'name' => $optin->name, 'parent_id' => $optin->parentId, 'status' => $optin->deleted ? 'historical' : ($optin->published ? 'published' : 'paused'), 'published_at' => $optin->publishedAt, ...self::numbers($goal, $range, $byOptin[$id] ?? []), ]; } return $rows; } /** * Every Optin whose counts this build can interpret, by id. * * @param iterable> $rows * @return array */ private static function interpretable(iterable $rows): array { $optins = []; foreach ($rows as $row) { $optin = InterpretedOptin::fromRow($row); if ($optin !== null) { $optins[$optin->id] = $optin; } } return $optins; } /** * The counters, bucketed by the Goal their Optin currently holds and by * the Optin itself. * * **This is the join, and it is one pass.** A stat row naming an Optin * this build cannot interpret is dropped here rather than counted into * some total — including a row whose Optin is missing entirely, which * cannot happen, because an Optin is soft-deleted and never removed * (ADR 0020). * * The range is re-applied even though the read already filtered on it, so * that {@see self::of()} answers for the window it was handed whatever * rows it was given. * * @param iterable> $rows * @param array $optins * @return array{0: array>>, 1: array>>} */ private static function bucket(StatRange $range, iterable $rows, array $optins): array { $byGoal = []; $byOptin = []; foreach ($rows as $row) { $optinId = (string) ($row['optin_id'] ?? ''); if (!isset($optins[$optinId]) || !$range->covers((string) ($row['stat_date'] ?? ''))) { continue; } $byGoal[$optins[$optinId]->goal->value][] = $row; $byOptin[$optinId][] = $row; } return [$byGoal, $byOptin]; } }