// TODO: these two dropdowns are similar; refactor and consolidate code?
on('click', '.chart-scope .dropdown-item', (e) => {
if (e.target.closest('#recent-items')) {
$('.active', e.target.closest('.dropdown-menu')).classList.remove('active');
e.target.classList.add('active');
$('a', e.target.closest('.dropdown')).innerText = e.target.innerText;
recentItemStats();
}
});
on('click', '.chart-scope .dropdown-item', (e) => {
if (e.target.closest('#days-documented')) {
$('.active', e.target.closest('.dropdown-menu')).classList.remove('active');
e.target.classList.add('active');
$('a', e.target.closest('.dropdown')).innerText = e.target.innerText;
recentDataSourceStats();
}
});
async function renderDashboard() {
await Promise.all([
recentItemStats(),
itemTypeStats(),
dataSourceHourStats(),
recentDataSourceStats()
]);
}
async function recentItemStats() {
const period = $('#recent-items .chart-scope .dropdown-item.active').dataset.period;
const itemStats = await app.ChartStats("periodical", tlz.openRepos[0].instance_id, {period});
if (!itemStats) {
// TODO: show empty dashboard -- tell user to import some data
return;
}
// The typical way of computing percent change is to subtract the first and last data points,
// then divide by the first data point which is the "point of reference" then multiply by 100
// to get the percent change. This is technically correct but ignores all the middle data points
// and is very sensitive to outliers at the first and last positions. Basically nothing in between
// matters if only the first and last are used to compute a trend. So here, I am averaging the
// first and second halves of the data set. I then subtract first half average from second half
// average, and divide that difference by the average over the whole series. This tells us the
// approximate trend of the latter half relative to the first half of data, and considers all
// data points. I think this makes much more sense in my trials.
let data = [], groups = [], total = 0, firstHalf = 0, secondHalf = 0;
itemStats.map((v) => {
data.push(v.count);
groups.push(v.period);
total += v.count;
// because of how we sort the groups in the DB query (oldest timestamp first), I've swapped
// the secondHalf and firstHalf vars here from the original implementation that showed the
// data from the most recent N days
// TODO: I switched it back :thinking: not sure when/why that changed, but the numbers look right now
if (data.length < itemStats.length / 2) {
firstHalf += v.count;
} else {
secondHalf += v.count;
}
});
const firstHalfAvg = firstHalf / (data.length/2),
secondHalfAvg = secondHalf / (data.length/2);
// As explained above, this is how the second half compares to the first half.
const recentItemsTrend = (secondHalfAvg - firstHalfAvg) / (total / data.length) * 100;
// clean up any prior info
$('svg', '#recent-items-trend-container')?.remove();
$('#recent-items-trend-container').classList.remove('text-red', 'text-green');
$('#recent-items-count').innerText = total.toLocaleString();
$('#recent-items-trend').innerText = Math.floor(Math.abs(recentItemsTrend)) + "%";
$('#recent-items-trend-container').classList.add(recentItemsTrend < 0 ? 'text-red' : 'text-green');
$('#recent-items-trend-container').innerHTML += recentItemsTrend > 0 ?
`` :
``;
const elem = $('#chart-recent-items-count');
let chartOptions = {};
if (!elem.chart) {
elem.chart = echarts.init(elem, null, {
renderer: 'svg'
});
chartOptions = {
xAxis: {
show: false,
type: 'category',
boundaryGap: false,
},
tooltip: {
trigger: 'axis',
},
yAxis: {
show: false
},
grid: {
left: 0,
top: 0,
right: 0,
bottom: 0
},
animationDuration: 2000,
series: [
{
name: "Items",
data: data,
type: 'line',
smooth: true,
lineStyle: {
width: 3
},
symbolSize: 8,
showSymbol: false,
emphasis: {
focus: 'series'
},
areaStyle: {
opacity: 0.8,
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: tabler.getColor("primary")
},
{
offset: 1,
color: tabler.hexToRgba(tabler.getColor("primary"), .1)
}
])
}
}
]
};
} else {
chartOptions = {
series: [
{
data: data
}
],
xAxis: {
data: null
}
};
}
elem.chart.setOption(chartOptions);
}
async function itemTypeStats() {
const stats = await app.ChartStats("classifications", tlz.openRepos[0].instance_id);
if (!stats) {
// TODO: show empty dashboard -- tell user to import some data
return;
}
let total = 0;
stats.map(point => total += point.value);
// clean up any prior info
$('#chart-item-types').replaceChildren();
$('#item-class-count').innerText = total.toLocaleString();
const elem = $('#chart-item-types');
let chartOptions = {};
if (!elem.chart) {
elem.chart = echarts.init(elem, null, {
renderer: 'svg'
});
chartOptions = {
tooltip: {
trigger: 'item'
},
grid: {
top: 0,
bottom: 0,
left: 0,
right: 0
},
series: [
{
name: 'Item type',
type: 'pie',
radius: ['50%', '80%'],
center: ['70%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
// fontSize: ,
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: stats
}
]
};
} else {
chartOptions = {
series: stats
};
}
elem.chart.setOption(chartOptions);
}
async function dataSourceHourStats() {
const series = await app.ChartStats("datasources", tlz.openRepos[0].instance_id);
if (!series) {
// TODO: show empty dashboard -- tell user to import some data
return;
}
// find max across all data so we can scale the bubble size (yeah this is a mouthful)
const dataMax = Math.max(...series.map(ds => Math.max(...ds.data.map(point => point[2]))));
// add some chart formatting to the returned data, and scale the bubble size based on the 3rd dimension of the data point
const minSymbolSize = 2, maxSymbolSize = 50;
for (let i = 0; i < series.length; i++) {
series[i] = {
...series[i], // includes name and data
type: 'scatter',
symbolSize: point => ((point[2]+minSymbolSize)/dataMax * (maxSymbolSize-minSymbolSize)) + minSymbolSize,
emphasis: {
focus: 'series'
}
};
}
// turns a decimal hour (of day; i.e. between 0 and 24) into a human-readable time
function hourDecimalToHumanTime(decimalHour) {
// fast path for whole numbers
if (decimalHour < 0 || decimalHour >= 24) return "";
if (decimalHour == 0) return "12am";
if (decimalHour == 12) return "noon";
if (decimalHour % 1 == 0) {
if (decimalHour > 12) return `${decimalHour%12}pm`;
return `${decimalHour}am`;
}
const hours = Math.floor(decimalHour);
const minutes = Math.round((decimalHour - hours) * 60);
const date = new Date();
date.setHours(hours, minutes, 0, 0);
return date.toLocaleString(undefined, { hour: 'numeric', minute: '2-digit' });
}
const elem = $('#chart-data-sources');
let chartOptions = {};
if (!elem.chart) {
elem.chart = echarts.init(elem, null, {
// renderer: 'svg'
});
chartOptions = {
legend: {
bottom: 0
},
xAxis: {
type: 'time',
splitLine: {
show: false
}
},
yAxis: {
splitLine: {
show: false
},
splitNumber: 8,
axisLabel: {
formatter: hourDecimalToHumanTime
},
inverse: true,
axisPointer: {
label: {
formatter: params => hourDecimalToHumanTime(params.value)
}
},
},
tooltip: {
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#283b56'
}
},
formatter: params => {
return `
${params.data[0]}, ${hourDecimalToHumanTime(params.data[1])}–${hourDecimalToHumanTime((params.data[1]+1)%24)} ${params.seriesName}:
${params.data[2]}`;
},
},
grid: {
top: 15,
right: 10,
left: 10,
bottom: 30,
containLabel: true,
},
dataZoom: [
{
type: 'inside',
xAxisIndex: [0],
},
{
type: 'inside',
yAxisIndex: [0],
}
],
animation: false,
animationDuration: 2000,
series: series
};
} else {
chartOptions = {
series: series
};
}
elem.chart.setOption(chartOptions);
}
async function recentDataSourceStats() {
$('#chart-days-documented').replaceChildren();
$('#chart-days-documented-legend').replaceChildren();
const days = Number($('#days-documented .chart-scope .dropdown-item.active').dataset.days);
const stats = await app.ChartStats("recent_data_sources", tlz.openRepos[0].instance_id, {days});
let totalItems = 0;
const dataSourceItemCounts = {};
const daysSeen = {};
if (stats)
{
for (const stat of stats) {
daysSeen[stat.date] = true;
dataSourceItemCounts[stat.data_source_name] =
(dataSourceItemCounts[stat.data_source_name] || 0) + stat.count;
totalItems += stat.count;
}
}
$('#days-documented-count').innerText = `${Object.keys(daysSeen).length.toLocaleString()} / ${days.toLocaleString()}`;
const daysSeenPct = Object.keys(daysSeen).length / days * 100;
const daysNotSeenPct = (days - Object.keys(daysSeen).length) / days * 100;
let i = 0;
for (const [dsName, count] of Object.entries(dataSourceItemCounts)) {
const color = tlz.colorClasses[i%tlz.colorClasses.length];
const div = document.createElement('div');
div.classList.add('progress-bar', "bg-"+color);
div.style.width = `${count/totalItems * daysSeenPct}%`;
const legend = cloneTemplate('#tpl-days-documented-legend');
$('.legend', legend).classList.add("bg-"+color);
$('.ds-name', legend).innerText = dsName;
$('.count', legend).innerText = count.toLocaleString();
$('#chart-days-documented').append(div);
$('#chart-days-documented-legend').append(legend);
i++;
}
$('#days-documented-percent').innerText = `${daysSeenPct.toFixed(1).toLocaleString()}%`;
if (daysSeenPct > 75) {
$('#days-documented-percent').classList.add('text-green');
$('#days-documented-percent').classList.remove('text-red', 'text-orange');
} else if (daysSeenPct > 50) {
$('#days-documented-percent').classList.add('text-orange');
$('#days-documented-percent').classList.remove('text-red', 'text-green');
} else {
$('#days-documented-percent').classList.add('text-red');
$('#days-documented-percent').classList.remove('text-green', 'text-orange');
}
}