Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
29cb4fe
feat: backend support for statistics custom date ranges
davwheat Aug 30, 2022
c657138
feat: use seconds-based timestamps on backend instead
davwheat Aug 30, 2022
82ebf32
feat: add frontend date selection option
davwheat Sep 3, 2022
e18130c
feat: add tests for lifetime and timed stats
davwheat Sep 4, 2022
555f865
Apply fixes from StyleCI
StyleCIBot Sep 4, 2022
3a17741
fix: add error alert when end date is after start date
davwheat Sep 4, 2022
ac6c8f6
fix: wrong label
davwheat Sep 4, 2022
247060b
fix: no data when start and end date are same day
davwheat Sep 4, 2022
6dc2a7d
fix: use utc dayjs for formatting custom date range on widget
davwheat Sep 4, 2022
b053661
chore: add dayjs as project dep
davwheat Sep 4, 2022
5802c60
fix: make end date inclusive
davwheat Sep 4, 2022
04068b9
feat: add integration test for custom date period
davwheat Sep 4, 2022
9f1ad48
Apply fixes from StyleCI
StyleCIBot Sep 4, 2022
b9e9dc8
fix: incorrect ts expect error comment
davwheat Sep 4, 2022
f6d9b9d
fix: add missing type
davwheat Sep 4, 2022
05ecfa0
fix: typing errors
davwheat Sep 4, 2022
1dac175
fix(tests): remove type from class attribute definition
davwheat Sep 4, 2022
a5ad687
fix: extract default values to function body
davwheat Sep 4, 2022
84c9a67
Apply fixes from StyleCI
StyleCIBot Sep 4, 2022
362e4ce
fix: typo
davwheat Sep 4, 2022
7ea4ca6
chore: use small modal
davwheat Sep 20, 2022
e32bc1c
fix: add missing `FormControl` class
davwheat Sep 20, 2022
6cc357b
fix: cast url params to int to enforce type
davwheat Sep 20, 2022
28ebeea
chore: `yarn format`
SychO9 Sep 29, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions extensions/statistics/js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
191 changes: 167 additions & 24 deletions extensions/statistics/js/src/admin/components/StatisticsWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand Down Expand Up @@ -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 thisPeriod = loadingSelectedEntity ? null : this.periods![this.selectedPeriod!];
const loadingSelectedEntity = (this.selectedPeriod === 'custom' ? this.loadingCustom : this.loadingTimed)[this.selectedEntity] !== 'loaded';

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 (
Expand All @@ -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>
Expand All @@ -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;

Expand Down Expand Up @@ -197,6 +317,8 @@ export default class StatisticsWidget extends DashboardWidget {
/>
)}
</>

{this.noData && <Placeholder text={app.translator.trans(`flarum-statistics.admin.statistics.no_data`)} />}
</div>
);
}
Expand All @@ -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 = [];
Expand All @@ -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');
}
}

Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
Loading