diff --git a/extensions/statistics/js/src/admin/components/StatisticsWidget.tsx b/extensions/statistics/js/src/admin/components/StatisticsWidget.tsx index 7d98515de..22dc445f4 100644 --- a/extensions/statistics/js/src/admin/components/StatisticsWidget.tsx +++ b/extensions/statistics/js/src/admin/components/StatisticsWidget.tsx @@ -25,11 +25,14 @@ export default class StatisticsWidget extends DashboardWidget { chart: any; - timedData: any; + timedData: Record = {}; lifetimeData: any; loadingLifetime = true; - loadingTimed = true; + loadingTimed: Record = this.entities.reduce((acc, curr) => { + acc[curr] = 'unloaded'; + return acc; + }, {} as Record); selectedEntity = 'users'; selectedPeriod: undefined | string; @@ -41,7 +44,6 @@ export default class StatisticsWidget extends DashboardWidget { super.oncreate(vnode); this.loadLifetimeData(); - this.loadTimedData(); } async loadLifetimeData() { @@ -62,39 +64,43 @@ export default class StatisticsWidget extends DashboardWidget { m.redraw(); } - async loadTimedData() { - this.loadingTimed = true; + async loadTimedData(model: string) { + this.loadingTimed[model] = 'loading'; m.redraw(); - const data = await app.request({ - method: 'GET', - url: app.forum.attribute('apiUrl') + '/statistics', - }); + try { + const data = await app.request({ + method: 'GET', + url: app.forum.attribute('apiUrl') + '/statistics', + params: { + period: 'timed', + model, + }, + }); - this.timedData = data; - this.loadingTimed = false; + this.timedData[model] = data; + this.loadingTimed[model] = 'loaded'; - // Create a Date object which represents the start of the day in the - // configured timezone. To do this we convert a UTC time into that timezone, - // reset to the first hour of the day, and then convert back into UTC time. - // We'll be working with seconds rather than milliseconds throughout too. - let todayDate = new Date(); - todayDate.setTime(todayDate.getTime() + this.timedData.timezoneOffset * 1000); - todayDate.setUTCHours(0, 0, 0, 0); - todayDate.setTime(todayDate.getTime() - this.timedData.timezoneOffset * 1000); + // Create a Date object which represents the start of the day. + let todayDate = new Date(); + todayDate.setUTCHours(0, 0, 0, 0); - const today = todayDate.getTime() / 1000; + const today = todayDate.getTime() / 1000; - this.periods = { - today: { start: today, end: today + 86400, step: 3600 }, - last_7_days: { start: today - 86400 * 7, end: today, step: 86400 }, - previous_7_days: { start: today - 86400 * 14, end: today - 86400 * 7, step: 86400 }, - last_28_days: { start: today - 86400 * 28, end: today, step: 86400 }, - previous_28_days: { start: today - 86400 * 28 * 2, end: today - 86400 * 28, step: 86400 }, - last_12_months: { start: today - 86400 * 364, end: today, step: 86400 * 7 }, - }; + this.periods = { + today: { start: today, end: today + 86400, step: 3600 }, + last_7_days: { start: today - 86400 * 7, end: today, step: 86400 }, + previous_7_days: { start: today - 86400 * 14, end: today - 86400 * 7, step: 86400 }, + last_28_days: { start: today - 86400 * 28, end: today, step: 86400 }, + previous_28_days: { start: today - 86400 * 28 * 2, end: today - 86400 * 28, step: 86400 }, + last_12_months: { start: today - 86400 * 364, end: today, step: 86400 * 7 }, + }; - this.selectedPeriod = 'last_7_days'; + this.selectedPeriod = 'last_7_days'; + } catch (e) { + console.error(e); + this.loadingTimed[model] = 'fail'; + } m.redraw(); } @@ -104,7 +110,13 @@ export default class StatisticsWidget extends DashboardWidget { } content() { - const thisPeriod = this.loadingTimed ? null : this.periods![this.selectedPeriod!]; + const loadingSelectedEntity = this.loadingTimed[this.selectedEntity] !== 'loaded'; + + const thisPeriod = loadingSelectedEntity ? null : this.periods![this.selectedPeriod!]; + + if (!this.timedData[this.selectedEntity] && this.loadingTimed[this.selectedEntity] === 'unloaded') { + this.loadTimedData(this.selectedEntity); + } return (
@@ -112,10 +124,10 @@ export default class StatisticsWidget extends DashboardWidget {
{app.translator.trans('flarum-statistics.admin.statistics.total_label')}
- {this.loadingTimed ? ( + {loadingSelectedEntity ? ( ) : ( - + {Object.keys(this.periods!).map((period) => (
- {this.loadingTimed ? : abbreviateNumber(thisPeriodCount as number)} + {loadingSelectedEntity ? : abbreviateNumber(thisPeriodCount as number)} {periodChange !== 0 && ( <> {' '} @@ -170,13 +182,21 @@ export default class StatisticsWidget extends DashboardWidget { })}
- {this.loadingTimed ? ( -
- -
- ) : ( -
- )} + <> + {loadingSelectedEntity ? ( +
+ +
+ ) : ( +
+ )} +
); } @@ -186,7 +206,6 @@ export default class StatisticsWidget extends DashboardWidget { return; } - const offset = this.timedData.timezoneOffset; const period = this.periods![this.selectedPeriod!]; const periodLength = period.end - period.start; const labels = []; @@ -197,19 +216,18 @@ export default class StatisticsWidget extends DashboardWidget { let label; if (period.step < 86400) { - label = dayjs.unix(i + offset).format('h A'); + label = dayjs.unix(i).format('h A'); } else { - label = dayjs.unix(i + offset).format('D MMM'); + label = dayjs.unix(i).format('D MMM'); if (period.step > 86400) { - label += ' - ' + dayjs.unix(i + offset + period.step - 1).format('D MMM'); + label += ' - ' + dayjs.unix(i + period.step - 1).format('D MMM'); } } labels.push(label); thisPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i, end: i + period.step })); - lastPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i - periodLength, end: i - periodLength + period.step })); } @@ -219,7 +237,9 @@ export default class StatisticsWidget extends DashboardWidget { datasets, }; - if (!this.chart) { + // If the dom element no longer exists, recreate the chart + // https://stackoverflow.com/a/2620373/11091039 + if (!this.chart || !(document.compareDocumentPosition(this.chart.parent) & 16)) { this.chart = new Chart(vnode.dom, { data, type: 'line', diff --git a/extensions/statistics/src/Api/Controller/ShowStatisticsData.php b/extensions/statistics/src/Api/Controller/ShowStatisticsData.php index ec4a0be9f..bbe4718d5 100644 --- a/extensions/statistics/src/Api/Controller/ShowStatisticsData.php +++ b/extensions/statistics/src/Api/Controller/ShowStatisticsData.php @@ -10,7 +10,6 @@ namespace Flarum\Statistics\Api\Controller; use DateTime; -use DateTimeZone; use Flarum\Discussion\Discussion; use Flarum\Http\RequestUtil; use Flarum\Post\Post; @@ -24,6 +23,7 @@ use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use Tobscure\JsonApi\Exception\InvalidParameterException; class ShowStatisticsData implements RequestHandlerInterface { @@ -70,20 +70,22 @@ class ShowStatisticsData implements RequestHandlerInterface $actor->assertAdmin(); $reportingPeriod = Arr::get($request->getQueryParams(), 'period'); + $model = Arr::get($request->getQueryParams(), 'model'); - return new JsonResponse($this->getResponse($reportingPeriod)); + return new JsonResponse($this->getResponse($model, $reportingPeriod)); } - private function getResponse(?string $period): array + private function getResponse(?string $model, ?string $period): array { if ($period === 'lifetime') { return $this->getLifetimeStatistics(); } - return array_merge( - $this->getTimedStatistics(), - ['timezoneOffset' => $this->getUserTimezone()->getOffset(new DateTime)] - ); + if (! Arr::exists($this->entities, $model)) { + throw new InvalidParameterException(); + } + + return $this->getTimedStatistics($model); } private function getLifetimeStatistics() @@ -95,61 +97,35 @@ class ShowStatisticsData implements RequestHandlerInterface }); } - private function getTimedStatistics() + private function getTimedStatistics(string $model) { - return $this->cache->remember('flarum-subscriptions.timed_stats', self::$lifetimeStatsCacheTtl, function () { - return array_map(function ($entity) { - return $this->getTimedCounts($entity[0], $entity[1]); - }, $this->entities); + return $this->cache->remember("flarum-subscriptions.timed_stats.$model", self::$lifetimeStatsCacheTtl, function () use ($model) { + return $this->getTimedCounts($this->entities[$model][0], $this->entities[$model][1]); }); } private function getTimedCounts(Builder $query, $column) { - // Calculate the offset between the server timezone (which is used for - // dates stored in the database) and the user's timezone (set via the - // settings table). We will use this to make sure we aggregate the - // daily/hourly statistics according to the user's timezone. - $offset = $this->getTimezoneOffset(); - $results = $query ->selectRaw( 'DATE_FORMAT( - @date := DATE_ADD('.$column.', INTERVAL ? SECOND), -- convert to user timezone + @date := '.$column.', IF(@date > ?, \'%Y-%m-%d %H:00:00\', \'%Y-%m-%d\') -- if within the last 24 hours, group by hour ) as time_group', - [$offset, new DateTime('-25 hours')] + [new DateTime('-25 hours')] ) ->selectRaw('COUNT(id) as count') ->where($column, '>', new DateTime('-365 days')) ->groupBy('time_group') ->pluck('count', 'time_group'); - // Now that we have the aggregated statistics, convert each time group - // into a UNIX timestamp. - $userTimezone = $this->getUserTimezone(); - $timed = []; - $results->each(function ($count, $time) use (&$timed, $userTimezone) { - $time = new DateTime($time, $userTimezone); + $results->each(function ($count, $time) use (&$timed) { + $time = new DateTime($time); $timed[$time->getTimestamp()] = (int) $count; }); return $timed; } - - private function getTimezoneOffset() - { - $now = new DateTime; - - $dataTimezone = new DateTimeZone(date_default_timezone_get()); - - return $this->getUserTimezone()->getOffset($now) - $dataTimezone->getOffset($now); - } - - private function getUserTimezone() - { - return new DateTimeZone($this->settings->get('flarum-statistics.timezone', date_default_timezone_get())); - } }