Skip to content

Commit f6c3c7c

Browse files
committed
quick-n-dirty osf-metrics report ui
1 parent 81c41de commit f6c3c7c

File tree

13 files changed

+510
-1
lines changed

13 files changed

+510
-1
lines changed

app/helpers/includes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { helper } from '@ember/component/helper';
2+
3+
export function includes([array, value]: [unknown[], unknown]): boolean {
4+
return array.includes(value);
5+
}
6+
7+
export default helper(includes);

app/modifiers/metrics-chart.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import Modifier from 'ember-modifier';
2+
import bb, { line, subchart } from 'billboard.js';
3+
4+
interface MetricsChartArgs {
5+
positional: [];
6+
named: {
7+
dataColumns?: Array<Array<string|number>>,
8+
dataRows?: Array<Array<string|number>>,
9+
};
10+
}
11+
12+
export default class MetricsChart extends Modifier<MetricsChartArgs> {
13+
chart: any = null;
14+
15+
didReceiveArguments() {
16+
if (this.chart) {
17+
this.chart.destroy();
18+
}
19+
this.chart = bb.generate({
20+
bindto: this.element,
21+
data: {
22+
type: line(),
23+
x: 'report_date',
24+
// columns: this.args.named.dataColumns,
25+
rows: this.args.named.dataRows,
26+
},
27+
axis: {
28+
x: {
29+
type: 'timeseries',
30+
tick: {
31+
format: '%Y-%m-%d',
32+
},
33+
},
34+
},
35+
subchart: {
36+
show: subchart(),
37+
showHandle: true,
38+
},
39+
tooltip: {
40+
grouped: false,
41+
linked: true,
42+
},
43+
});
44+
}
45+
46+
willRemove() {
47+
if (this.chart) {
48+
this.chart.destroy();
49+
}
50+
}
51+
}
52+
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import Controller from '@ember/controller';
2+
import { action, get } from '@ember/object';
3+
import { tracked } from '@glimmer/tracking';
4+
import { MetricsReportAttrs } from './route';
5+
6+
7+
type ReportFields = {
8+
keywordFields: string[],
9+
numericFields: string[],
10+
};
11+
12+
13+
function gatherFields(obj: any): ReportFields {
14+
const keywordFields: string[] = []
15+
const numericFields: string[] = []
16+
for (const fieldName in obj) {
17+
if (fieldName === 'report_date' || fieldName === 'timestamp') {
18+
continue;
19+
}
20+
const fieldValue = obj[fieldName];
21+
switch (typeof fieldValue) {
22+
case 'string':
23+
keywordFields.push(fieldName);
24+
break;
25+
case 'number':
26+
numericFields.push(fieldName);
27+
break;
28+
case 'object':
29+
const nestedFields = gatherFields(fieldValue);
30+
keywordFields.push(...nestedFields.keywordFields.map(
31+
nestedFieldName => `${fieldName}.${nestedFieldName}`,
32+
));
33+
numericFields.push(...nestedFields.numericFields.map(
34+
nestedFieldName => `${fieldName}.${nestedFieldName}`,
35+
));
36+
break;
37+
default:
38+
console.log(`ignoring unexpected ${fieldName}: ${fieldValue}`)
39+
}
40+
}
41+
return {
42+
keywordFields,
43+
numericFields,
44+
};
45+
}
46+
47+
48+
export default class MetricsReportDetailController extends Controller {
49+
queryParams = [
50+
{ daysBack: { scope: 'controller' as const } },
51+
'yFields',
52+
'xGroupField',
53+
'xGroupFilter',
54+
]
55+
56+
@tracked daysBack: string = '13';
57+
@tracked model: MetricsReportAttrs[] = [];
58+
@tracked yFields: string[] = [];
59+
@tracked xGroupField?: string;
60+
@tracked xField: string = 'report_date';
61+
@tracked xGroupFilter: string = '';
62+
63+
get reportFields(): ReportFields {
64+
const aReport: MetricsReportAttrs = this.model![0];
65+
return gatherFields(aReport);
66+
}
67+
68+
get chartRows(): Array<Array<string|number|null>>{
69+
if (!this.xGroupField) {
70+
const fieldNames = [this.xField, ...this.yFields];
71+
const rows = this.model.map(
72+
datum => fieldNames.map(
73+
fieldName => (get(datum, fieldName) as string | number | undefined) ?? null,
74+
),
75+
);
76+
return [fieldNames, ...rows];
77+
}
78+
const groupedFieldNames = new Set<string>();
79+
const rowsByX: any = {};
80+
for (const datum of this.model) {
81+
const xValue = get(datum, this.xField) as string;
82+
if (!rowsByX[xValue]) {
83+
rowsByX[xValue] = {};
84+
}
85+
const groupName = get(datum, this.xGroupField) as string;
86+
if (!this.xGroupFilter || groupName.includes(this.xGroupFilter)) {
87+
this.yFields.forEach(fieldName => {
88+
const groupedField = `${groupName} ${fieldName}`;
89+
groupedFieldNames.add(groupedField);
90+
const fieldValue = get(datum, fieldName);
91+
rowsByX[xValue][groupedField] = fieldValue;
92+
});
93+
}
94+
}
95+
const rows = Object.entries(rowsByX).map(
96+
([xValue, rowData]: [string, any]) => {
97+
const yValues = [...groupedFieldNames].map(
98+
groupedFieldName => (rowData[groupedFieldName] as string | number | undefined) ?? null,
99+
);
100+
return [xValue, ...yValues];
101+
},
102+
);
103+
return [
104+
[this.xField, ...groupedFieldNames],
105+
...rows,
106+
];
107+
}
108+
109+
@action
110+
yFieldToggle(fieldName: string) {
111+
if (this.yFields.includes(fieldName)) {
112+
this.yFields = this.yFields.filter(f => f !== fieldName);
113+
} else {
114+
this.yFields = [...this.yFields, fieldName];
115+
}
116+
}
117+
}
118+
119+
declare module '@ember/controller' {
120+
interface Registry {
121+
'osf-metrics.report-detail': MetricsReportDetailController;
122+
}
123+
}
124+
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<LoadingIndicator />
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import Route from '@ember/routing/route';
2+
import config from 'ember-get-config';
3+
4+
const {
5+
OSF: {
6+
apiUrl,
7+
},
8+
} = config;
9+
10+
export interface MetricsReportAttrs {
11+
report_date: string, // YYYY-MM-DD
12+
[attr: string]: string | number | object,
13+
}
14+
15+
interface MetricsReport {
16+
id: string;
17+
type: string;
18+
attributes: MetricsReportAttrs;
19+
}
20+
21+
interface RecentMetricsReportResponse {
22+
data: MetricsReport[];
23+
}
24+
25+
export default class OsfMetricsRoute extends Route {
26+
queryParams = {
27+
daysBack: {
28+
refreshModel: true,
29+
},
30+
yFields: {
31+
replace: true,
32+
},
33+
xGroupField: {
34+
replace: true,
35+
},
36+
xGroupFilter: {
37+
replace: true,
38+
},
39+
}
40+
41+
async model(params: { daysBack: string, reportName?: string }) {
42+
const url = `${apiUrl}/_/metrics/reports/${params.reportName}/recent/?days_back=${params.daysBack}`
43+
const response = await fetch(url);
44+
const responseJson: RecentMetricsReportResponse = await response.json();
45+
return responseJson.data.map(datum => datum.attributes);
46+
}
47+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<p>
2+
days back
3+
{{#each (array '7' '13' '31' '73' '371') as |daysBackOption|}}
4+
| <LinkTo @query={{hash daysBack=daysBackOption}}>{{daysBackOption}}</LinkTo>
5+
{{/each}}
6+
</p>
7+
<p>
8+
data fields (y-axis)
9+
{{#each this.reportFields.numericFields as |fieldName|}}
10+
| <label>
11+
<Input
12+
@type='checkbox'
13+
@checked={{includes this.yFields fieldName}}
14+
{{on 'input' (fn this.yFieldToggle fieldName)}}
15+
/>
16+
{{fieldName}}
17+
</label>
18+
{{/each}}
19+
</p>
20+
{{#if this.reportFields.keywordFields.length}}
21+
<p>
22+
group by
23+
<PowerSelect
24+
@options={{this.reportFields.keywordFields}}
25+
@selected={{this.xGroupField}}
26+
@onChange={{fn (mut this.xGroupField)}}
27+
as |item|
28+
>
29+
{{item}}
30+
</PowerSelect>
31+
</p>
32+
<p>
33+
<label>
34+
filter groups
35+
<Input @type='text' @value={{mut this.xGroupFilter}} />
36+
</label>
37+
</p>
38+
{{/if}}
39+
{{#if (and this.model.length this.yFields.length)}}
40+
<section>
41+
<div {{metrics-chart dataRows=this.chartRows}}></div>
42+
</section>
43+
{{/if}}

app/osf-metrics/route.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import Route from '@ember/routing/route';
2+
import config from 'ember-get-config';
3+
4+
const {
5+
OSF: {
6+
apiUrl,
7+
},
8+
} = config;
9+
10+
interface MetricsReportName {
11+
id: string;
12+
type: 'metrics-report-name';
13+
links: {
14+
recent: string,
15+
};
16+
}
17+
18+
interface MetricsReportNameResponse {
19+
data: MetricsReportName[];
20+
}
21+
22+
export default class OsfMetricsRoute extends Route {
23+
async model() {
24+
const url = `${apiUrl}/_/metrics/reports/`;
25+
const response = await fetch(url);
26+
const responseJson: MetricsReportNameResponse = await response.json();
27+
return responseJson.data.map(metricsReport => metricsReport.id);
28+
}
29+
}

app/osf-metrics/styles.scss

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.OsfMetrics {
2+
display: flex;
3+
flex-direction: column;
4+
align-items: center;
5+
}
6+
7+
.OsfMetrics > p {
8+
max-width: 62vw;
9+
}
10+
11+
.OsfMetrics > section {
12+
width: 87vw;
13+
}
14+
15+
.OsfMetrics :global(.active) {
16+
font-weight: bold;
17+
}

app/osf-metrics/template.hbs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{{page-title 'osf metrics'}}
2+
<section local-class='OsfMetrics'>
3+
<h1>osf metrics</h1>
4+
<p>
5+
reports
6+
{{#each @model as |reportName|}}
7+
|
8+
<LinkTo @route='osf-metrics.report-detail' @model={{reportName}}>{{reportName}}</LinkTo>
9+
{{/each}}
10+
</p>
11+
{{outlet}}
12+
</section>

app/router.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ Router.map(function() {
4242
});
4343
});
4444
this.route('support');
45+
this.route('osf-metrics', function() {
46+
this.route('report-detail', { path: '/:reportName' });
47+
});
4548
this.route('meetings', function() {
4649
this.route('detail', { path: '/:meeting_id' });
4750
});

0 commit comments

Comments
 (0)