mirror of
https://github.com/flarum/core.git
synced 2025-08-02 14:37:49 +02: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:
@@ -7,15 +7,15 @@
|
|||||||
"frappe-charts": "^1.6.2"
|
"frappe-charts": "^1.6.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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",
|
"@flarum/prettier-config": "^1.0.0",
|
||||||
|
"@types/mithril": "^2.0.11",
|
||||||
"flarum-tsconfig": "^1.0.2",
|
"flarum-tsconfig": "^1.0.2",
|
||||||
|
"flarum-webpack-config": "^2.0.0",
|
||||||
|
"prettier": "^2.7.1",
|
||||||
"typescript": "^4.7.4",
|
"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": {
|
"scripts": {
|
||||||
"dev": "webpack --mode development --watch",
|
"dev": "webpack --mode development --watch",
|
||||||
|
@@ -3,13 +3,24 @@ import app from 'flarum/admin/app';
|
|||||||
import SelectDropdown from 'flarum/common/components/SelectDropdown';
|
import SelectDropdown from 'flarum/common/components/SelectDropdown';
|
||||||
import Button from 'flarum/common/components/Button';
|
import Button from 'flarum/common/components/Button';
|
||||||
import abbreviateNumber from 'flarum/common/utils/abbreviateNumber';
|
import abbreviateNumber from 'flarum/common/utils/abbreviateNumber';
|
||||||
|
import extractText from 'flarum/common/utils/extractText';
|
||||||
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
|
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
|
||||||
|
import Placeholder from 'flarum/common/components/Placeholder';
|
||||||
import icon from 'flarum/common/helpers/icon';
|
import icon from 'flarum/common/helpers/icon';
|
||||||
|
|
||||||
import DashboardWidget, { IDashboardWidgetAttrs } from 'flarum/admin/components/DashboardWidget';
|
import DashboardWidget, { IDashboardWidgetAttrs } from 'flarum/admin/components/DashboardWidget';
|
||||||
|
|
||||||
|
import StatisticsWidgetDateSelectionModal, { IDateSelection, IStatisticsWidgetDateSelectionModalAttrs } from './StatisticsWidgetDateSelectionModal';
|
||||||
|
|
||||||
import type Mithril from 'mithril';
|
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
|
// @ts-expect-error No typings available
|
||||||
import { Chart } from 'frappe-charts';
|
import { Chart } from 'frappe-charts';
|
||||||
|
|
||||||
@@ -25,14 +36,23 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
|
|
||||||
chart: any;
|
chart: any;
|
||||||
|
|
||||||
|
customPeriod: IDateSelection | null = null;
|
||||||
|
|
||||||
timedData: Record<string, undefined | any> = {};
|
timedData: Record<string, undefined | any> = {};
|
||||||
lifetimeData: any;
|
lifetimeData: any;
|
||||||
|
customPeriodData: Record<string, undefined | any> = {};
|
||||||
|
|
||||||
|
noData: boolean = false;
|
||||||
|
|
||||||
loadingLifetime = true;
|
loadingLifetime = true;
|
||||||
loadingTimed: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'> = this.entities.reduce((acc, curr) => {
|
loadingTimed: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'> = this.entities.reduce((acc, curr) => {
|
||||||
acc[curr] = 'unloaded';
|
acc[curr] = 'unloaded';
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>);
|
}, {} 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';
|
selectedEntity = 'users';
|
||||||
selectedPeriod: undefined | string;
|
selectedPeriod: undefined | string;
|
||||||
@@ -105,17 +125,74 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
m.redraw();
|
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() {
|
className() {
|
||||||
return 'StatisticsWidget';
|
return 'StatisticsWidget';
|
||||||
}
|
}
|
||||||
|
|
||||||
content() {
|
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') {
|
if (this.selectedPeriod === 'custom') {
|
||||||
this.loadTimedData(this.selectedEntity);
|
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 (
|
return (
|
||||||
@@ -128,16 +205,56 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
<LoadingIndicator size="small" display="inline" />
|
<LoadingIndicator size="small" display="inline" />
|
||||||
) : (
|
) : (
|
||||||
<SelectDropdown disabled={loadingSelectedEntity} buttonClassName="Button Button--text" caretIcon="fas fa-caret-down">
|
<SelectDropdown disabled={loadingSelectedEntity} buttonClassName="Button Button--text" caretIcon="fas fa-caret-down">
|
||||||
{Object.keys(this.periods!).map((period) => (
|
{Object.keys(this.periods!)
|
||||||
<Button
|
.map((period) => (
|
||||||
key={period}
|
<Button
|
||||||
active={period === this.selectedPeriod}
|
key={period}
|
||||||
onclick={this.changePeriod.bind(this, period)}
|
active={period === this.selectedPeriod}
|
||||||
icon={period === this.selectedPeriod ? 'fas fa-check' : true}
|
onclick={this.changePeriod.bind(this, period)}
|
||||||
>
|
icon={period === this.selectedPeriod ? 'fas fa-check' : true}
|
||||||
{app.translator.trans(`flarum-statistics.admin.statistics.${period}_label`)}
|
>
|
||||||
</Button>
|
{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>
|
</SelectDropdown>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -148,11 +265,14 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
const thisPeriodCount = loadingSelectedEntity
|
const thisPeriodCount = loadingSelectedEntity
|
||||||
? app.translator.trans('flarum-statistics.admin.statistics.loading')
|
? app.translator.trans('flarum-statistics.admin.statistics.loading')
|
||||||
: this.getPeriodCount(entity, thisPeriod!);
|
: this.getPeriodCount(entity, thisPeriod!);
|
||||||
const lastPeriodCount = loadingSelectedEntity
|
const lastPeriodCount =
|
||||||
? app.translator.trans('flarum-statistics.admin.statistics.loading')
|
this.selectedPeriod === 'custom'
|
||||||
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
|
? null
|
||||||
|
: loadingSelectedEntity
|
||||||
|
? app.translator.trans('flarum-statistics.admin.statistics.loading')
|
||||||
|
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
|
||||||
const periodChange =
|
const periodChange =
|
||||||
loadingSelectedEntity || lastPeriodCount === 0
|
loadingSelectedEntity || lastPeriodCount === 0 || lastPeriodCount === null
|
||||||
? 0
|
? 0
|
||||||
: (((thisPeriodCount as number) - (lastPeriodCount as number)) / (lastPeriodCount as number)) * 100;
|
: (((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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -206,7 +328,14 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
return;
|
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 periodLength = period.end - period.start;
|
||||||
const labels = [];
|
const labels = [];
|
||||||
const thisPeriod = [];
|
const thisPeriod = [];
|
||||||
@@ -216,12 +345,17 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
let label;
|
let label;
|
||||||
|
|
||||||
if (period.step < 86400) {
|
if (period.step < 86400) {
|
||||||
label = dayjs.unix(i).format('h A');
|
label = dayjs.unix(i).utc().format('h A');
|
||||||
} else {
|
} else {
|
||||||
label = dayjs.unix(i).format('D MMM');
|
label = dayjs.unix(i).utc().format('D MMM');
|
||||||
|
|
||||||
if (period.step > 86400) {
|
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 }));
|
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 datasets = [{ values: lastPeriod }, { values: thisPeriod }];
|
||||||
const data = {
|
const data = {
|
||||||
labels,
|
labels,
|
||||||
@@ -275,7 +418,7 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getPeriodCount(entity: string, period: { start: number; end: number }) {
|
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;
|
let count = 0;
|
||||||
|
|
||||||
for (const t in timed) {
|
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;
|
padding: 12px 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.Placeholder {
|
||||||
|
padding-bottom: 32px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
|
@@ -1,11 +1,21 @@
|
|||||||
flarum-statistics:
|
flarum-statistics:
|
||||||
|
|
||||||
##
|
##
|
||||||
# UNIQUE KEYS - The following keys are used in only one location each.
|
# UNIQUE KEYS - The following keys are used in only one location each.
|
||||||
##
|
##
|
||||||
|
|
||||||
# Translations in this namespace are used by the admin interface.
|
# Translations in this namespace are used by the admin interface.
|
||||||
admin:
|
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.
|
# These translations are used in the Statistics dashboard widget.
|
||||||
statistics:
|
statistics:
|
||||||
@@ -16,9 +26,12 @@ flarum-statistics:
|
|||||||
mini_heading: Forum statistics
|
mini_heading: Forum statistics
|
||||||
previous_28_days_label: Previous 28 days
|
previous_28_days_label: Previous 28 days
|
||||||
previous_7_days_label: Previous 7 days
|
previous_7_days_label: Previous 7 days
|
||||||
|
custom_label: Choose custom range...
|
||||||
|
custom_label_specified: "{fromDate} to {toDate}"
|
||||||
loading: => core.ref.loading
|
loading: => core.ref.loading
|
||||||
posts_heading: => core.ref.posts
|
posts_heading: => core.ref.posts
|
||||||
today_label: Today
|
today_label: Today
|
||||||
total_label: Total
|
total_label: Total
|
||||||
users_heading: => core.ref.users
|
users_heading: => core.ref.users
|
||||||
view_full: View more statistics
|
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;
|
namespace Flarum\Statistics\Api\Controller;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
use DateTime;
|
use DateTime;
|
||||||
use Flarum\Discussion\Discussion;
|
use Flarum\Discussion\Discussion;
|
||||||
use Flarum\Http\RequestUtil;
|
use Flarum\Http\RequestUtil;
|
||||||
@@ -69,20 +70,39 @@ class ShowStatisticsData implements RequestHandlerInterface
|
|||||||
// control panel.
|
// control panel.
|
||||||
$actor->assertAdmin();
|
$actor->assertAdmin();
|
||||||
|
|
||||||
$reportingPeriod = Arr::get($request->getQueryParams(), 'period');
|
$query = $request->getQueryParams();
|
||||||
$model = Arr::get($request->getQueryParams(), 'model');
|
|
||||||
|
|
||||||
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') {
|
if ($period === 'lifetime') {
|
||||||
return $this->getLifetimeStatistics();
|
return $this->getLifetimeStatistics();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! Arr::exists($this->entities, $model)) {
|
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);
|
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
|
$results = $query
|
||||||
->selectRaw(
|
->selectRaw(
|
||||||
'DATE_FORMAT(
|
'DATE_FORMAT(
|
||||||
@@ -115,7 +143,8 @@ class ShowStatisticsData implements RequestHandlerInterface
|
|||||||
[new DateTime('-25 hours')]
|
[new DateTime('-25 hours')]
|
||||||
)
|
)
|
||||||
->selectRaw('COUNT(id) as count')
|
->selectRaw('COUNT(id) as count')
|
||||||
->where($column, '>', new DateTime('-365 days'))
|
->where($column, '>', $startDate)
|
||||||
|
->where($column, '<=', $endDate)
|
||||||
->groupBy('time_group')
|
->groupBy('time_group')
|
||||||
->pluck('count', '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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user