> */ public function inRange(StatRange $range): array { return $this->db->results( Connection::TABLE_STATS, 'SELECT optin_id, stat_date, kind, `count` FROM %i WHERE scope = \'\' AND stat_date BETWEEN %s AND %s', $range->from, $range->to ); } /** * The earliest day each kind was ever counted on, keyed by kind. * * ======================================================================== * TWO MILESTONES, DERIVED, WITH NOTHING WRITTEN TO SUPPORT THEM. * ======================================================================== * "The first [[Impression]]" and "the first [[Conversion]]" are milestones * #94 asks to be recorded once, and this table already holds the answer: * one row per Optin per day per kind, never pruned and never deleted. So * they are read rather than stored, which is the better shape twice over — * a derived date cannot disagree with the counters it comes from, and a * `MIN` **cannot move forwards**, so "recorded once" is a property of the * arithmetic rather than a guard somebody has to remember. * * That rests on properties of THIS table and does not generalise. An * erasure request deletes [[Lead]] rows and never a counter (ADR 0018), an * Optin is soft-deleted so its counters outlive it (ADR 0020), and there * is no delete path here at all — {@see \WConvert\Database\Connection} * offers none this class could call. * * ======================================================================== * NO WINDOW, WHICH IS WHY IT IS NOT ON THE DASHBOARD'S PAYLOAD. * ======================================================================== * A milestone is all-time by definition. {@see self::inRange()} answers * for a window a merchant chose, and a first conversion that moved when * somebody changed the analytics window would not be a milestone at all — * so the two reads stay separate rather than one gaining a mode. * * **A scan, and a cheaper one than the dashboard's.** There is no * secondary index and `optin_id` is leftmost, so this cannot use the * primary key — but it reads three columns' worth of nothing and returns * at most four rows, for a screen an admin opens on demand (309 ms at 1.32M * rows, ADR 0140). That is the same asymmetry `inRange()` books, * without even a `WHERE` to be unable to use. * * A kind this build has no case for is dropped rather than passed on: * {@see StatKind} is a closed set and it is enforced on the way out of * storage as well as on the way in. * * @return array Kind => the `Y-m-d` it was first counted on. */ public function firstDays(): array { $first = []; foreach ($this->db->results(Connection::TABLE_STATS, self::FIRST_DAYS) as $row) { $kind = StatKind::tryFrom((string) ($row['kind'] ?? '')); $day = (string) ($row['first_day'] ?? ''); if ($kind !== null && $day !== '') { $first[$kind->value] = $day; } } return $first; } /** * Count one act. * * `$statDate` is passed IN rather than read here, and that is the design: * "the site's day" is a WordPress question, answered once at the request * boundary by {@see StatDay::today()}, so this class has no clock, no * timezone and nothing to stub. A batch of events flushed together is * therefore also stamped together, which is what a visitor's page view * actually was. */ public function increment(string $optinId, StatKind $kind, string $statDate, string $scope = ''): void { $this->db->upsert(Connection::TABLE_STATS, self::INCREMENT, $optinId, $statDate, $kind->value, $scope); } }