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:
@@ -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();
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user