1
0
mirror of https://github.com/flarum/core.git synced 2025-07-29 20:50:28 +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:
David Wheatley
2022-09-29 12:12:54 +01:00
committed by GitHub
parent 973ec32e13
commit 76788efaba
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();
}
}