mirror of
https://github.com/flarum/framework.git
synced 2024-11-23 07:01:03 +08:00
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:
parent
973ec32e13
commit
76788efaba
|
@ -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",
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -109,6 +109,10 @@
|
|||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.Placeholder {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
/*!
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user