feat(statistics): support for custom date ranges (#3622)

* feat: backend support for statistics custom date ranges
* feat: use seconds-based timestamps on backend instead
* feat: add frontend date selection option
* feat: add tests for lifetime and timed stats
* fix: add error alert when end date is after start date
* fix: wrong label
* fix: no data when start and end date are same day
* fix: use utc dayjs for formatting custom date range on widget
* chore: add dayjs as project dep
* fix: make end date inclusive
* feat: add integration test for custom date period
* fix: incorrect ts expect error comment
* fix: add missing type
* fix: typing errors
* fix(tests): remove type from class attribute definition
* fix: extract default values to function body
* fix: typo
* chore: use small modal
* fix: add missing `FormControl` class
* fix: cast url params to int to enforce type
* chore: `yarn format`

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>
This commit is contained in:
David Wheatley 2022-09-29 12:12:54 +01:00 committed by GitHub
parent 973ec32e13
commit 76788efaba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 678 additions and 37 deletions

View File

@ -7,15 +7,15 @@
"frappe-charts": "^1.6.2"
},
"devDependencies": {
"@types/mithril": "^2.0.11",
"prettier": "^2.7.1",
"flarum-webpack-config": "^2.0.0",
"webpack": "^5.73.0",
"webpack-cli": "^4.10.0",
"@flarum/prettier-config": "^1.0.0",
"@types/mithril": "^2.0.11",
"flarum-tsconfig": "^1.0.2",
"flarum-webpack-config": "^2.0.0",
"prettier": "^2.7.1",
"typescript": "^4.7.4",
"typescript-coverage-report": "^0.6.4"
"typescript-coverage-report": "^0.6.4",
"webpack": "^5.73.0",
"webpack-cli": "^4.10.0"
},
"scripts": {
"dev": "webpack --mode development --watch",

View File

@ -3,13 +3,24 @@ import app from 'flarum/admin/app';
import SelectDropdown from 'flarum/common/components/SelectDropdown';
import Button from 'flarum/common/components/Button';
import abbreviateNumber from 'flarum/common/utils/abbreviateNumber';
import extractText from 'flarum/common/utils/extractText';
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
import Placeholder from 'flarum/common/components/Placeholder';
import icon from 'flarum/common/helpers/icon';
import DashboardWidget, { IDashboardWidgetAttrs } from 'flarum/admin/components/DashboardWidget';
import StatisticsWidgetDateSelectionModal, { IDateSelection, IStatisticsWidgetDateSelectionModalAttrs } from './StatisticsWidgetDateSelectionModal';
import type Mithril from 'mithril';
import dayjs from 'dayjs';
import dayjsUtc from 'dayjs/plugin/utc';
import dayjsLocalizedFormat from 'dayjs/plugin/localizedFormat';
dayjs.extend(dayjsUtc);
dayjs.extend(dayjsLocalizedFormat);
// @ts-expect-error No typings available
import { Chart } from 'frappe-charts';
@ -25,14 +36,23 @@ export default class StatisticsWidget extends DashboardWidget {
chart: any;
customPeriod: IDateSelection | null = null;
timedData: Record<string, undefined | any> = {};
lifetimeData: any;
customPeriodData: Record<string, undefined | any> = {};
noData: boolean = false;
loadingLifetime = true;
loadingTimed: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'> = this.entities.reduce((acc, curr) => {
acc[curr] = 'unloaded';
return acc;
}, {} as Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>);
loadingCustom: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'> = this.entities.reduce((acc, curr) => {
acc[curr] = 'unloaded';
return acc;
}, {} as Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>);
selectedEntity = 'users';
selectedPeriod: undefined | string;
@ -105,17 +125,74 @@ export default class StatisticsWidget extends DashboardWidget {
m.redraw();
}
async loadCustomRangeData(model: string): Promise<void> {
this.loadingCustom[model] = 'loading';
m.redraw();
// We clone so we can check that the same period is still selected
// once the HTTP request is complete and the data is to be displayed
const range = { ...this.customPeriod };
try {
const data = await app.request({
method: 'GET',
url: app.forum.attribute('apiUrl') + '/statistics',
params: {
period: 'custom',
model,
dateRange: {
start: range.start,
end: range.end,
},
},
});
if (JSON.stringify(range) !== JSON.stringify(this.customPeriod)) {
// The range this method was called with is no longer the selected.
// Bail out here.
return;
}
this.customPeriodData[model] = data;
this.loadingCustom[model] = 'loaded';
m.redraw();
} catch (e) {
if (JSON.stringify(range) !== JSON.stringify(this.customPeriod)) {
// The range this method was called with is no longer the selected.
// Bail out here.
return;
}
console.error(e);
this.loadingCustom[model] = 'fail';
}
}
className() {
return 'StatisticsWidget';
}
content() {
const loadingSelectedEntity = this.loadingTimed[this.selectedEntity] !== 'loaded';
const loadingSelectedEntity = (this.selectedPeriod === 'custom' ? this.loadingCustom : this.loadingTimed)[this.selectedEntity] !== 'loaded';
const thisPeriod = loadingSelectedEntity ? null : this.periods![this.selectedPeriod!];
const thisPeriod = loadingSelectedEntity
? null
: this.selectedPeriod === 'custom'
? {
start: this.customPeriod?.end!,
end: this.customPeriod?.end!,
step: 86400,
}
: this.periods![this.selectedPeriod!];
if (!this.timedData[this.selectedEntity] && this.loadingTimed[this.selectedEntity] === 'unloaded') {
this.loadTimedData(this.selectedEntity);
if (this.selectedPeriod === 'custom') {
if (!this.customPeriodData[this.selectedEntity] && this.loadingCustom[this.selectedEntity] === 'unloaded') {
this.loadCustomRangeData(this.selectedEntity);
}
} else {
if (!this.timedData[this.selectedEntity] && this.loadingTimed[this.selectedEntity] === 'unloaded') {
this.loadTimedData(this.selectedEntity);
}
}
return (
@ -128,16 +205,56 @@ export default class StatisticsWidget extends DashboardWidget {
<LoadingIndicator size="small" display="inline" />
) : (
<SelectDropdown disabled={loadingSelectedEntity} buttonClassName="Button Button--text" caretIcon="fas fa-caret-down">
{Object.keys(this.periods!).map((period) => (
<Button
key={period}
active={period === this.selectedPeriod}
onclick={this.changePeriod.bind(this, period)}
icon={period === this.selectedPeriod ? 'fas fa-check' : true}
>
{app.translator.trans(`flarum-statistics.admin.statistics.${period}_label`)}
</Button>
))}
{Object.keys(this.periods!)
.map((period) => (
<Button
key={period}
active={period === this.selectedPeriod}
onclick={this.changePeriod.bind(this, period)}
icon={period === this.selectedPeriod ? 'fas fa-check' : true}
>
{app.translator.trans(`flarum-statistics.admin.statistics.${period}_label`)}
</Button>
))
.concat([
<Button
key="custom"
active={this.selectedPeriod === 'custom'}
onclick={() => {
const attrs: IStatisticsWidgetDateSelectionModalAttrs = {
onModalSubmit: (dates: IDateSelection) => {
if (JSON.stringify(dates) === JSON.stringify(this.customPeriod)) {
// If same period is selected, don't reload data
return;
}
this.customPeriodData = {};
Object.keys(this.loadingCustom).forEach((k) => (this.loadingCustom[k] = 'unloaded'));
this.customPeriod = dates;
this.changePeriod('custom');
},
} as any;
// If we have a custom period set already,
// let's prefill the modal with it
if (this.customPeriod) {
attrs.value = this.customPeriod;
}
app.modal.show(StatisticsWidgetDateSelectionModal as any, attrs as any);
}}
icon={this.selectedPeriod === 'custom' ? 'fas fa-check' : true}
>
{this.selectedPeriod === 'custom'
? extractText(
app.translator.trans(`flarum-statistics.admin.statistics.custom_label_specified`, {
fromDate: dayjs.utc(this.customPeriod!.start! * 1000).format('ll'),
toDate: dayjs.utc(this.customPeriod!.end! * 1000).format('ll'),
})
)
: app.translator.trans(`flarum-statistics.admin.statistics.custom_label`)}
</Button>,
])}
</SelectDropdown>
)}
</div>
@ -148,11 +265,14 @@ export default class StatisticsWidget extends DashboardWidget {
const thisPeriodCount = loadingSelectedEntity
? app.translator.trans('flarum-statistics.admin.statistics.loading')
: this.getPeriodCount(entity, thisPeriod!);
const lastPeriodCount = loadingSelectedEntity
? app.translator.trans('flarum-statistics.admin.statistics.loading')
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
const lastPeriodCount =
this.selectedPeriod === 'custom'
? null
: loadingSelectedEntity
? app.translator.trans('flarum-statistics.admin.statistics.loading')
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
const periodChange =
loadingSelectedEntity || lastPeriodCount === 0
loadingSelectedEntity || lastPeriodCount === 0 || lastPeriodCount === null
? 0
: (((thisPeriodCount as number) - (lastPeriodCount as number)) / (lastPeriodCount as number)) * 100;
@ -197,6 +317,8 @@ export default class StatisticsWidget extends DashboardWidget {
/>
)}
</>
{this.noData && <Placeholder text={app.translator.trans(`flarum-statistics.admin.statistics.no_data`)} />}
</div>
);
}
@ -206,7 +328,14 @@ export default class StatisticsWidget extends DashboardWidget {
return;
}
const period = this.periods![this.selectedPeriod!];
const period =
this.selectedPeriod === 'custom'
? {
start: this.customPeriod?.start!,
end: this.customPeriod?.end!,
step: 86400,
}
: this.periods![this.selectedPeriod!];
const periodLength = period.end - period.start;
const labels = [];
const thisPeriod = [];
@ -216,12 +345,17 @@ export default class StatisticsWidget extends DashboardWidget {
let label;
if (period.step < 86400) {
label = dayjs.unix(i).format('h A');
label = dayjs.unix(i).utc().format('h A');
} else {
label = dayjs.unix(i).format('D MMM');
label = dayjs.unix(i).utc().format('D MMM');
if (period.step > 86400) {
label += ' - ' + dayjs.unix(i + period.step - 1).format('D MMM');
label +=
' - ' +
dayjs
.unix(i + period.step - 1)
.utc()
.format('D MMM');
}
}
@ -231,6 +365,15 @@ export default class StatisticsWidget extends DashboardWidget {
lastPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i - periodLength, end: i - periodLength + period.step }));
}
if (thisPeriod.length === 0) {
this.noData = true;
m.redraw();
return;
} else {
this.noData = false;
m.redraw();
}
const datasets = [{ values: lastPeriod }, { values: thisPeriod }];
const data = {
labels,
@ -275,7 +418,7 @@ export default class StatisticsWidget extends DashboardWidget {
}
getPeriodCount(entity: string, period: { start: number; end: number }) {
const timed: Record<string, number> = this.timedData[entity];
const timed: Record<string, number> = (this.selectedPeriod === 'custom' ? this.customPeriodData : this.timedData)[entity];
let count = 0;
for (const t in timed) {

View File

@ -0,0 +1,161 @@
import app from 'flarum/admin/app';
import ItemList from 'flarum/common/utils/ItemList';
import generateElementId from 'flarum/admin/utils/generateElementId';
import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal';
import Mithril from 'mithril';
import Button from 'flarum/common/components/Button';
import dayjs from 'dayjs';
import dayjsUtc from 'dayjs/plugin/utc';
dayjs.extend(dayjsUtc);
export interface IDateSelection {
/**
* Timestamp (seconds, not ms) for start date
*/
start: number;
/**
* Timestamp (seconds, not ms) for end date
*/
end: number;
}
export interface IStatisticsWidgetDateSelectionModalAttrs extends IInternalModalAttrs {
onModalSubmit: (dates: IDateSelection) => void;
value?: IDateSelection;
}
interface IStatisticsWidgetDateSelectionModalState {
inputs: {
startDateVal: string;
endDateVal: string;
};
ids: {
startDate: string;
endDate: string;
};
}
export default class StatisticsWidgetDateSelectionModal extends Modal<IStatisticsWidgetDateSelectionModalAttrs> {
/* @ts-expect-error core typings don't allow us to set the type of the state attr :( */
state: IStatisticsWidgetDateSelectionModalState = {
inputs: {
startDateVal: dayjs().format('YYYY-MM-DD'),
endDateVal: dayjs().format('YYYY-MM-DD'),
},
ids: {
startDate: generateElementId(),
endDate: generateElementId(),
},
};
oninit(vnode: Mithril.Vnode<IStatisticsWidgetDateSelectionModalAttrs, this>) {
super.oninit(vnode);
if (this.attrs.value) {
this.state.inputs = {
startDateVal: dayjs.utc(this.attrs.value.start * 1000).format('YYYY-MM-DD'),
endDateVal: dayjs.utc(this.attrs.value.end * 1000).format('YYYY-MM-DD'),
};
}
}
className(): string {
return 'StatisticsWidgetDateSelectionModal Modal--small';
}
title(): Mithril.Children {
return app.translator.trans('flarum-statistics.admin.date_selection_modal.title');
}
content(): Mithril.Children {
return <div class="Modal-body">{this.items().toArray()}</div>;
}
items(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
items.add('intro', <p>{app.translator.trans('flarum-statistics.admin.date_selection_modal.description')}</p>, 100);
items.add(
'date_start',
<div class="Form-group">
<label htmlFor={this.state.ids.startDate}>{app.translator.trans('flarum-statistics.admin.date_selection_modal.start_date')}</label>
<input
type="date"
id={this.state.ids.startDate}
value={this.state.inputs.startDateVal}
onchange={this.updateState('startDateVal')}
className="FormControl"
/>
</div>,
90
);
items.add(
'date_end',
<div class="Form-group">
<label htmlFor={this.state.ids.endDate}>{app.translator.trans('flarum-statistics.admin.date_selection_modal.end_date')}</label>
<input
type="date"
id={this.state.ids.endDate}
value={this.state.inputs.endDateVal}
onchange={this.updateState('endDateVal')}
className="FormControl"
/>
</div>,
80
);
items.add(
'submit',
<Button class="Button Button--primary" type="submit">
{app.translator.trans('flarum-statistics.admin.date_selection_modal.submit_button')}
</Button>,
0
);
return items;
}
updateState(field: keyof IStatisticsWidgetDateSelectionModalState['inputs']): (e: InputEvent) => void {
return (e: InputEvent) => {
this.state.inputs[field] = (e.currentTarget as HTMLInputElement).value;
};
}
submitData(): IDateSelection {
// We force 'zulu' time (UTC)
return {
start: Math.floor(+dayjs.utc(this.state.inputs.startDateVal + 'Z') / 1000),
// Ensures that the end date is the end of the day
end: Math.floor(
+dayjs
.utc(this.state.inputs.endDateVal + 'Z')
.hour(23)
.minute(59)
.second(59)
.millisecond(999) / 1000
),
};
}
onsubmit(e: SubmitEvent): void {
e.preventDefault();
const data = this.submitData();
if (data.end < data.start) {
this.alertAttrs = {
type: 'error',
controls: app.translator.trans('flarum-statistics.admin.date_selection_modal.errors.end_before_start'),
};
return;
}
this.attrs.onModalSubmit(data);
this.hide();
}
}

View File

@ -109,6 +109,10 @@
padding: 12px 16px;
text-align: center;
}
.Placeholder {
padding-bottom: 32px;
}
}
/*!

View File

@ -1,11 +1,21 @@
flarum-statistics:
##
# UNIQUE KEYS - The following keys are used in only one location each.
##
# Translations in this namespace are used by the admin interface.
admin:
# These translations are used in the date selection modal.
date_selection_modal:
description: |
Pick a custom date range to display statistics for. Loading data may take
multiple minutes on forums with a lot of activity.
end_date: End date (inclusive)
errors:
end_before_start: The end date must be after the start date.
start_date: Start date (inclusive)
submit_button: Confirm date range
title: Choose custom date range
# These translations are used in the Statistics dashboard widget.
statistics:
@ -16,9 +26,12 @@ flarum-statistics:
mini_heading: Forum statistics
previous_28_days_label: Previous 28 days
previous_7_days_label: Previous 7 days
custom_label: Choose custom range...
custom_label_specified: "{fromDate} to {toDate}"
loading: => core.ref.loading
posts_heading: => core.ref.posts
today_label: Today
total_label: Total
users_heading: => core.ref.users
view_full: View more statistics
no_data: There is no data available for this date range.

View File

@ -9,6 +9,7 @@
namespace Flarum\Statistics\Api\Controller;
use Carbon\Carbon;
use DateTime;
use Flarum\Discussion\Discussion;
use Flarum\Http\RequestUtil;
@ -69,20 +70,39 @@ class ShowStatisticsData implements RequestHandlerInterface
// control panel.
$actor->assertAdmin();
$reportingPeriod = Arr::get($request->getQueryParams(), 'period');
$model = Arr::get($request->getQueryParams(), 'model');
$query = $request->getQueryParams();
return new JsonResponse($this->getResponse($model, $reportingPeriod));
$reportingPeriod = Arr::get($query, 'period');
$model = Arr::get($query, 'model');
$customDateRange = Arr::get($query, 'dateRange');
return new JsonResponse($this->getResponse($model, $reportingPeriod, $customDateRange));
}
private function getResponse(?string $model, ?string $period): array
private function getResponse(?string $model, ?string $period, ?array $customDateRange): array
{
if ($period === 'lifetime') {
return $this->getLifetimeStatistics();
}
if (! Arr::exists($this->entities, $model)) {
throw new InvalidParameterException();
throw new InvalidParameterException('A model must be specified');
}
if ($period === 'custom') {
$start = (int) $customDateRange['start'];
$end = (int) $customDateRange['end'];
if (! $customDateRange || ! $start || ! $end) {
throw new InvalidParameterException('A custom date range must be specified');
}
// Seconds-based timestamps
$startRange = Carbon::createFromTimestampUTC($start)->toDateTime();
$endRange = Carbon::createFromTimestampUTC($end)->toDateTime();
// We can't really cache this
return $this->getTimedCounts($this->entities[$model][0], $this->entities[$model][1], $startRange, $endRange);
}
return $this->getTimedStatistics($model);
@ -104,8 +124,16 @@ class ShowStatisticsData implements RequestHandlerInterface
});
}
private function getTimedCounts(Builder $query, $column)
private function getTimedCounts(Builder $query, string $column, ?DateTime $startDate = null, ?DateTime $endDate = null)
{
if (! isset($startDate)) {
$startDate = new DateTime('-365 days');
}
if (! isset($endDate)) {
$endDate = new DateTime();
}
$results = $query
->selectRaw(
'DATE_FORMAT(
@ -115,7 +143,8 @@ class ShowStatisticsData implements RequestHandlerInterface
[new DateTime('-25 hours')]
)
->selectRaw('COUNT(id) as count')
->where($column, '>', new DateTime('-365 days'))
->where($column, '>', $startDate)
->where($column, '<=', $endDate)
->groupBy('time_group')
->pluck('count', 'time_group');

View File

@ -0,0 +1,107 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Statistics\tests\integration\api;
use Carbon\Carbon;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
class CanRequestCustomTimedStatisticsTest extends TestCase
{
use RetrievesAuthorizedUsers;
/**
* @var Carbon
*/
protected $nowTime;
protected function setUp(): void
{
parent::setUp();
$this->nowTime = Carbon::now()->subDays(10);
$this->extension('flarum-statistics');
$this->prepareDatabase($this->getDatabaseData());
}
protected function getDatabaseData(): array
{
return [
'users' => [
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()],
['id' => 2, 'username' => 'normal', 'email' => 'normal@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()->subDays(1)],
['id' => 3, 'username' => 'normal2', 'email' => 'normal2@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()->subDays(2)],
],
'discussions' => [
['id' => 1, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 2, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 3, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 4, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(2), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()],
['id' => 2, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(1)],
['id' => 3, 'discussion_id' => 3, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(1)],
['id' => 4, 'discussion_id' => 4, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(2)],
['id' => 5, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 2, 'created_at' => $this->nowTime->copy()],
],
];
}
/**
* @test
*/
public function can_request_timed_stats()
{
$time = $this->nowTime->copy();
$start = $time->copy()->subDays(1)->startOfDay()->getTimestamp();
$end = $time->copy()->endOfDay()->getTimestamp();
$timeStart = $time->copy()->startOfDay();
$models = [
'users' => [
$timeStart->copy()->getTimestamp() => 1,
$timeStart->copy()->subDays(1)->getTimestamp() => 1,
], 'discussions' => [
$timeStart->copy()->getTimestamp() => 1,
$timeStart->copy()->subDays(1)->getTimestamp() => 2,
], 'posts' => [
$timeStart->copy()->getTimestamp() => 2,
$timeStart->copy()->subDays(1)->getTimestamp() => 2,
]
];
foreach ($models as $model => $data) {
$response = $this->send(
$this->request('GET', '/api/statistics', ['authenticatedAs' => 1])->withQueryParams([
'model' => $model,
'period' => 'custom',
'dateRange' => [
'start' => $start,
'end' => $end,
],
])
);
$body = json_decode($response->getBody()->getContents(), true);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(
$data,
$body,
);
}
}
}

View File

@ -0,0 +1,85 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Statistics\tests\integration\api;
use Carbon\Carbon;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
class CanRequestLifetimeStatisticsTest extends TestCase
{
use RetrievesAuthorizedUsers;
/**
* @var Carbon
*/
protected $nowTime;
protected function setUp(): void
{
parent::setUp();
$this->nowTime = Carbon::now();
$this->extension('flarum-statistics');
$this->prepareDatabase($this->getDatabaseData());
}
protected function getDatabaseData(): array
{
return [
'users' => [
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1],
['id' => 2, 'username' => 'normal', 'email' => 'normal@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->subDays(1)],
],
'discussions' => [
['id' => 1, 'title' => __CLASS__, 'created_at' => $this->nowTime, 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 2, 'title' => __CLASS__, 'created_at' => $this->nowTime->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 3, 'title' => __CLASS__, 'created_at' => $this->nowTime->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 4, 'title' => __CLASS__, 'created_at' => $this->nowTime->subDays(2), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1],
['id' => 2, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1],
['id' => 3, 'discussion_id' => 3, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1],
['id' => 4, 'discussion_id' => 4, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1],
['id' => 5, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 2],
],
];
}
/**
* @test
*/
public function can_request_lifetime_stats()
{
$response = $this->send(
$this->request('GET', '/api/statistics', ['authenticatedAs' => 1])->withQueryParams([
'period' => 'lifetime',
])
);
$body = json_decode($response->getBody()->getContents(), true);
$db = $this->getDatabaseData();
$this->assertEquals(200, $response->getStatusCode());
$this->assertEqualsCanonicalizing(
[
'users' => count($db['users']),
'discussions' => count($db['discussions']),
'posts' => count($db['posts']),
],
$body
);
}
}

View File

@ -0,0 +1,99 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Statistics\tests\integration\api;
use Carbon\Carbon;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
class CanRequestTimedStatisticsTest extends TestCase
{
use RetrievesAuthorizedUsers;
/**
* @var Carbon
*/
protected $nowTime;
protected function setUp(): void
{
parent::setUp();
$this->nowTime = Carbon::now()->subDays(10);
$this->extension('flarum-statistics');
$this->prepareDatabase($this->getDatabaseData());
}
protected function getDatabaseData(): array
{
return [
'users' => [
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()],
['id' => 2, 'username' => 'normal', 'email' => 'normal@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()->subDays(1)],
],
'discussions' => [
['id' => 1, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 2, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 3, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 4, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(2), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()],
['id' => 2, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(1)],
['id' => 3, 'discussion_id' => 3, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(1)],
['id' => 4, 'discussion_id' => 4, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(2)],
['id' => 5, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 2, 'created_at' => $this->nowTime->copy()],
],
];
}
/**
* @test
*/
public function can_request_timed_stats()
{
$time = $this->nowTime->copy();
$time->setTime(0, 0, 0, 0);
$models = [
'users' => [
$time->copy()->getTimestamp() => 1,
$time->copy()->subDays(1)->getTimestamp() => 1,
], 'discussions' => [
$time->copy()->getTimestamp() => 1,
$time->copy()->subDays(1)->getTimestamp() => 2,
$time->copy()->subDays(2)->getTimestamp() => 1,
], 'posts' => [
$time->copy()->getTimestamp() => 2,
$time->copy()->subDays(1)->getTimestamp() => 2,
$time->copy()->subDays(2)->getTimestamp() => 1,
]
];
foreach ($models as $model => $data) {
$response = $this->send(
$this->request('GET', '/api/statistics', ['authenticatedAs' => 1])->withQueryParams([
'model' => $model,
])
);
$body = json_decode($response->getBody()->getContents(), true);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEqualsCanonicalizing(
$data,
$body
);
}
}
}