*/ private const TREND = array( 'users' => 0.06, 'newUsers' => 0.005, 'sessions' => -0.01, 'views' => -0.005, 'engaged' => -0.016, 'duration' => -0.035, ); /** * Days back from today the trend runs for. */ private const TREND_DAYS = 84; /** * How long a reading of the site's clock is kept, in seconds. A request * is far shorter; a long-lived PHP worker must not freeze "today". */ private const CLOCK_TTL = 60; /** * The site's clock as last read, until it is a minute old or * reset_clock() is called. * * @var array{0: string, 1: int}|null */ private static ?array $clock = null; /** * When the clock was read (Unix time), to know when it is old. * * @var int */ private static int $clock_read_at = 0; /** * Additive metrics derived from the day's totals. * * @var string[] */ public const COUNT_METRICS = array( 'activeUsers', 'newUsers', 'sessions', 'screenPageViews', 'engagedSessions', 'sessionDuration', 'userEngagementDuration', 'eventCount' ); /** * One day's site totals. A future day is all zeros; today is scaled to * the hours elapsed so far, like a real, still-filling day. Today is the * site's today, as the dashboard counts it: its range for "the last 28 * days" ends on the site's yesterday, and that day must be a whole one. * * @param string $ymd Date, Ymd. * @return array */ public static function day( string $ymd ): array { static $cache = array(); static $cache_day = ''; [ $today, $hour ] = self::clock(); // The trend runs back from today, so a new day starts a new cache. if ( $cache_day !== $today ) { $cache = array(); $cache_day = $today; } $key = $ymd === $today ? $ymd . '|' . $hour : $ymd; if ( ! isset( $cache[ $key ] ) ) { $cache[ $key ] = self::compute_day( $ymd, $today, $hour ); } return $cache[ $key ]; } /** * The site's today (Ymd). */ public static function today(): string { return self::clock()[0]; } /** * Forgets the clock read so far, so the next lookup reads it again. A * request never needs this (it reads the clock once, and is over before * the reading is old); a test that moves the clock does. */ public static function reset_clock(): void { self::$clock = null; self::$clock_read_at = 0; } /** * The site's today (Ymd) and the hour it is there (0-23). The site's own * clock, not UTC's: a site east of UTC is a day ahead for the first * hours of its day, and asking UTC would make the last point of its * chart a half-filled day. Read once and kept for the request: a report * looks up thousands of days, and the answer cannot change meanwhile. Read * again when the reading is a minute old, so a worker that outlives the * request does not keep yesterday. * * @return array{0: string, 1: int} */ private static function clock(): array { if ( null === self::$clock || time() - self::$clock_read_at >= self::CLOCK_TTL ) { $now = (string) current_time( 'mysql' ); self::$clock = array( substr( str_replace( '-', '', $now ), 0, 8 ), (int) substr( $now, 11, 2 ) ); self::$clock_read_at = time(); } return self::$clock; } /** * Computes one day's site totals (see day()). * * Visitors carry noise of the day's own (a busy Tuesday, a slow Thursday), * independent of every other day, so the previous period is not a copy of * the current one: the two lines on the trend chart differ in shape and * cross, and a long range does not repeat one wave every four weeks. How * visits behave (sessions per visitor, pages per session, how long * visits ran) follows a four-week pattern, so those ratios add up the same * in every 28 days and a comparison of the default range with the one * before it still shows the trend, not the luck of the draw. * * @param string $ymd Date, Ymd. * @param string $today The site's today, Ymd. * @param int $hour The hour it is on the site, 0-23. * @return array */ private static function compute_day( string $ymd, string $today, int $hour ): array { $utc = new \DateTimeZone( 'UTC' ); $parsed = \DateTime::createFromFormat( '!Ymd', $ymd, $utc ); $now = \DateTime::createFromFormat( '!Ymd', $today, $utc ); if ( false === $parsed || false === $now || $ymd > $today ) { return array_fill_keys( self::COUNT_METRICS, 0 ); } $slot = (int) floor( $parsed->getTimestamp() / 86400 ) % 28; $cycle = new Prng( (int) crc32( 'tagsmith-demo-cycle|' . $slot ) ); $rng = new Prng( (int) crc32( 'tagsmith-demo-day|' . $ymd ) ); $jitter = static fn( int $percent ): float => $rng->int( 100 - $percent, 100 + $percent ) / 100; $weekday = in_array( (int) $parsed->format( 'N' ), array( 6, 7 ), true ) ? self::WEEKEND : 1.0; $season = 1 + self::SEASON * sin( 2 * M_PI * (int) $parsed->format( 'z' ) / 365 ); $partial = $ymd === $today ? ( $hour + 1 ) / 24 : 1.0; $back = min( (int) $parsed->diff( $now )->days, self::TREND_DAYS ) / 28; $trend = static fn( string $measure ): float => ( 1 + self::TREND[ $measure ] ) ** -$back; $users = max( 1, (int) round( self::USERS_PER_DAY * $weekday * $season * $partial * $trend( 'users' ) * $jitter( self::NOISE ) ) ); $sessions = (int) round( $users * $cycle->int( 126, 136 ) / 100 * $trend( 'sessions' ) * $jitter( 1 ) ); $views = (int) round( $sessions * $cycle->int( 193, 207 ) / 100 * $trend( 'views' ) * $jitter( 1 ) ); $duration = (int) round( $sessions * $cycle->int( 115, 145 ) * $trend( 'duration' ) * $jitter( 1 ) ); return array( 'activeUsers' => $users, 'newUsers' => (int) round( $users * $cycle->int( 60, 66 ) / 100 * $trend( 'newUsers' ) * $jitter( 1 ) ), 'sessions' => $sessions, 'screenPageViews' => $views, 'engagedSessions' => (int) round( $sessions * $cycle->int( 56, 62 ) / 100 * $trend( 'engaged' ) * $jitter( 1 ) ), 'sessionDuration' => $duration, 'userEngagementDuration' => (int) round( $duration * 0.7 ), 'eventCount' => (int) round( $views * $cycle->int( 260, 340 ) / 100 ), ); } /** * Site totals summed over some days. * * @param string[] $dates Dates, Ymd. * @return array */ public static function sum( array $dates ): array { $sum = array_fill_keys( self::COUNT_METRICS, 0 ); foreach ( $dates as $date ) { foreach ( self::day( $date ) as $metric => $value ) { $sum[ $metric ] += $value; } } return $sum; } /** * A metric's value for a slice of the site (share of every day, each * day floored, so slices never add up to more than the whole). * Additive metrics only; see rate() for the others. * * @param string $metric Metric name. * @param string[] $dates Dates, Ymd. * @param float $share The slice's share of site traffic, 0-1. */ public static function count( string $metric, array $dates, float $share ): int { $total = 0; foreach ( $dates as $date ) { $total += (int) floor( ( self::day( $date )[ $metric ] ?? 0 ) * $share ); } return $total; } /** * A non-additive metric (engagementRate, bounceRate, * averageSessionDuration) for some days, shifted by a small, * deterministic per-slice offset so rows are not all identical. * * @param string $metric Metric name. * @param string[] $dates Dates, Ymd. * @param string $slice A key for the slice ('' for the whole site). * @return float|null Null when $metric is not a known rate. */ public static function rate( string $metric, array $dates, string $slice = '' ): ?float { $sum = self::sum( $dates ); $sessions = max( 1, $sum['sessions'] ); $offset = '' === $slice ? 0.0 : ( self::hash01( 'rate|' . $slice ) - 0.5 ); switch ( $metric ) { case 'engagementRate': return round( min( 0.9, max( 0.2, $sum['engagedSessions'] / $sessions + 0.16 * $offset ) ), 4 ); case 'bounceRate': return round( 1 - min( 0.9, max( 0.2, $sum['engagedSessions'] / $sessions + 0.16 * $offset ) ), 4 ); case 'averageSessionDuration': return round( $sum['sessionDuration'] / $sessions * ( 1 + 0.5 * $offset ), 2 ); } return null; } /** * Shares for a ranked list: 1/(rank+1.5), scaled to add up to $total. * * @param string[] $values Values, most popular first. * @param float $total What the shares add up to (the rest is "other"). * @return array Value => share. */ public static function ranked( array $values, float $total ): array { $weights = array(); foreach ( array_values( $values ) as $rank => $value ) { $weights[ (string) $value ] = 1 / ( $rank + 1.5 ); } $sum = array_sum( $weights ); return array_map( static fn( float $w ): float => $sum > 0 ? $w / $sum * $total : 0.0, $weights ); } /** * The site's page shares: its own paths, in a stable, hash-based * popularity order, adding up to PAGES_SHARE. * * @param string[] $paths The site's paths. * @return array Path => share, most popular first. */ public static function page_shares( array $paths ): array { $paths = array_values( array_unique( array_map( 'strval', $paths ) ) ); usort( $paths, static fn( string $a, string $b ): int => crc32( 'rank|' . $a ) <=> crc32( 'rank|' . $b ) ); return self::ranked( $paths, self::PAGES_SHARE ); } /** * A small share (0.4% to 2%) for a path that is not one of the site's * own posts, e.g. a URL named by hand. * * @param string $path A path. */ public static function unknown_page_share( string $path ): float { return 0.004 + 0.016 * self::hash01( 'unknown|' . $path ); } /** * A deterministic number in [0, 1) for a key. * * @param string $key Any string. */ public static function hash01( string $key ): float { return ( crc32( $key ) % 10000 ) / 10000; } /** * Active users in the last 30 minutes: about 1.5 half-hours of today's * full-day users, varying from minute to minute. Clamped to 3-40. */ public static function active_now(): int { $minute = (string) floor( time() / 60 ); $rng = new Prng( (int) crc32( 'tagsmith-demo-rt|' . $minute ) ); $today = \DateTime::createFromFormat( '!Ymd', self::today(), new \DateTimeZone( 'UTC' ) ); $weekday = ( false !== $today && in_array( (int) $today->format( 'N' ), array( 6, 7 ), true ) ) ? self::WEEKEND : 1.0; $active = (int) round( self::USERS_PER_DAY * $weekday / 48 * 1.5 * $rng->int( 80, 120 ) / 100 ); return min( 40, max( 3, $active ) ); } }