Skip to content

Commit b09feef

Browse files
committed
add stats charts
1 parent ac4c95b commit b09feef

File tree

10 files changed

+293
-15
lines changed

10 files changed

+293
-15
lines changed

src/controllers/rest/impl/SubmissionController.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,14 @@ export class SubmissionController extends BaseRestController {
143143
return wad.content;
144144
}
145145

146+
@Get("/:roundId/validAndVerifiedSubmissions")
147+
@Authorize("login")
148+
@(Returns(StatusCodes.OK, Array).Of(SubmissionModel))
149+
@Returns(StatusCodes.BAD_REQUEST, BadRequest)
150+
public getValidAndVerifiedSubmissions(@PathParams("roundId") roundId: number): Promise<SubmissionModel[]> {
151+
return this.submissionService.getAllEntries(roundId, true);
152+
}
153+
146154
private async getWad(
147155
roundId: number,
148156
entryId: number,

src/db/dao/SubmissionDao.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,22 @@ export class SubmissionDao extends AbstractDao<SubmissionModel> {
6060
});
6161
}
6262

63-
public getAllSubmissions(roundId: number, transaction?: EntityManager): Promise<SubmissionModel[]> {
63+
public getAllSubmissions(
64+
roundId: number,
65+
validAndVerifiedOnly = false,
66+
transaction?: EntityManager,
67+
): Promise<SubmissionModel[]> {
68+
const manager = this.getEntityManager(transaction);
69+
if (validAndVerifiedOnly) {
70+
return manager.find({
71+
relations: ["confirmation", "status"],
72+
where: {
73+
submissionRoundId: roundId,
74+
submissionValid: true,
75+
verified: true,
76+
},
77+
});
78+
}
6479
return this.getEntityManager(transaction).find({
6580
relations: ["confirmation", "status"],
6681
where: {

src/db/repo/SubmissionRepo.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ export class SubmissionRepo {
4949
return this.submissionDao.getSubmission(id);
5050
}
5151

52-
public getAllSubmissions(roundId: number): Promise<SubmissionModel[]> {
53-
return this.submissionDao.getAllSubmissions(roundId);
52+
public getAllSubmissions(roundId: number, validAndVerifiedOnly = false): Promise<SubmissionModel[]> {
53+
return this.submissionDao.getAllSubmissions(roundId, validAndVerifiedOnly);
5454
}
5555

5656
public async setSubmissionStatus(status: SubmissionStatusModel): Promise<SubmissionModel> {

src/public/assets/vendor/bootstrap/css/bootstrap.min.css

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/public/assets/vendor/bootstrap/css/bootstrap.min.css.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/public/assets/vendor/bootstrap/js/bootstrap.bundle.min.js

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/public/assets/vendor/bootstrap/js/bootstrap.bundle.min.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/public/secure/admin.ejs

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,25 @@
5858
</div>
5959
</div>
6060
</div>
61+
<div class="row mt-5">
62+
<div class="col-md-12">
63+
<div class="card border-primary mb-3 ml-3" id="statsPanel">
64+
<div class="card-header">Stats</div>
65+
<div class="card-body">
66+
<div class="row">
67+
<div class="col-md-4">
68+
<div id="recordFormatDist" class="chart-container"></div></div>
69+
<div class="col-md-4">
70+
<div id="mapAuthorChart"></div>
71+
</div>
72+
<div class="col-md-4">
73+
<div id="submissionTypeChart"></div>
74+
</div>
75+
</div>
76+
</div>
77+
</div>
78+
</div>
79+
</div>
6180
<div class="row mt-5">
6281
<div class="col-md-12">
6382
<div class="card border-primary mb-3 ml-3" id="validationSubmissionsPanel">
@@ -539,6 +558,17 @@
539558
<%- include('../snippets/scripts.ejs'); %>
540559
<script type="text/javascript" src="../assets/vendor/Enchanter/enchanter.js"></script>
541560
<script type="text/javascript" src="../assets/custom/js/spinWheel.js"></script>
561+
562+
<script src="https://cdn.amcharts.com/lib/5/index.js"></script>
563+
<script src="https://cdn.amcharts.com/lib/5/xy.js"></script>
564+
<script src="https://cdn.amcharts.com/lib/5/themes/Animated.js"></script>
565+
<script src="https://cdn.amcharts.com/lib/5/locales/de_DE.js"></script>
566+
<script src="https://cdn.amcharts.com/lib/5/geodata/germanyLow.js"></script>
567+
<script src="https://cdn.amcharts.com/lib/5/fonts/notosans-sc.js"></script>
568+
<script src="https://cdn.amcharts.com/lib/5/percent.js"></script>
569+
<script src="https://cdn.amcharts.com/lib/5/themes/Animated.js"></script>
570+
571+
542572
</body>
543573
</html>
544574
@@ -1686,5 +1716,229 @@
16861716
}
16871717
});
16881718
}
1719+
<% if (indexModel && indexModel.currentActiveRound) { %>
1720+
((() => {
1721+
async function createSubmissionCharts() {
1722+
1723+
let submissions = [];
1724+
try {
1725+
const roundId = <%- indexModel.currentActiveRound.id %>;
1726+
const result = await fetch(`${baseUrl}/submission/${roundId}/validAndVerifiedSubmissions`);
1727+
submissions = await result.json();
1728+
} catch (e) {
1729+
console.error(e.message);
1730+
submissions = [];
1731+
}
1732+
1733+
createRecordedFormatChart(submissions);
1734+
createAuthorDistributionChart(submissions);
1735+
createSubmissionTypeChart(submissions);
1736+
}
1737+
1738+
function createChart(containerId, title, data, colors) {
1739+
const statsBody = document.querySelector(`#${containerId}`);
1740+
if (!statsBody) {
1741+
console.error(`Chart container #${containerId} not found`);
1742+
return;
1743+
}
1744+
1745+
const chartContainer = document.createElement('div');
1746+
chartContainer.id = `${containerId}Chart`;
1747+
chartContainer.style.width = '100%';
1748+
chartContainer.style.height = '300px';
1749+
1750+
statsBody.innerHTML = `<h6 class="mb-3 text-white text-center">${title}</h6>`;
1751+
statsBody.appendChild(chartContainer);
1752+
1753+
const root = am5.Root.new(`${containerId}Chart`);
1754+
root.setThemes([am5themes_Animated.new(root)]);
1755+
1756+
const chart = root.container.children.push(am5percent.PieChart.new(root, {
1757+
layout: root.verticalLayout,
1758+
innerRadius: am5.percent(50)
1759+
}));
1760+
1761+
// Add "No data" message
1762+
const noDataLabel = chart.children.unshift(am5.Label.new(root, {
1763+
text: "No chart data available",
1764+
fontSize: 14,
1765+
fontWeight: "500",
1766+
textAlign: "center",
1767+
x: am5.percent(50),
1768+
y: am5.percent(50),
1769+
centerX: am5.percent(50),
1770+
centerY: am5.percent(50),
1771+
fill: am5.color("#6c757d")
1772+
}));
1773+
1774+
const series = chart.series.push(am5percent.PieSeries.new(root, {
1775+
valueField: "count",
1776+
categoryField: "category",
1777+
alignLabels: false
1778+
}));
1779+
1780+
series.get("colors").set("colors", colors.map(color => am5.color(color)));
1781+
1782+
series.labels.template.setAll({
1783+
textType: "circular",
1784+
centerX: 0,
1785+
centerY: 0,
1786+
fontSize: "12px",
1787+
fontWeight: "500",
1788+
fill: am5.color("#ffffff"),
1789+
oversizedBehavior: "wrap"
1790+
});
1791+
1792+
series.slices.template.setAll({
1793+
stroke: am5.color("#495057"),
1794+
strokeWidth: 2,
1795+
cornerRadius: 3,
1796+
shadowOpacity: 0.1,
1797+
shadowOffsetX: 2,
1798+
shadowOffsetY: 2,
1799+
shadowBlur: 5,
1800+
shadowColor: am5.color("#000000"),
1801+
toggleKey: "active"
1802+
});
1803+
1804+
series.slices.template.states.create("hover", {
1805+
scale: 1.05,
1806+
shadowOpacity: 0.3,
1807+
shadowOffsetX: 3,
1808+
shadowOffsetY: 3,
1809+
shadowBlur: 8
1810+
});
1811+
1812+
series.slices.template.states.create("active", {
1813+
scale: 1.1,
1814+
shiftRadius: 15
1815+
});
1816+
1817+
series.slices.template.set("cursorOverStyle", "pointer");
1818+
series.slices.template.set("tooltipText", "{category}: {count} submissions ({valuePercentTotal.formatNumber('#.0')}%)");
1819+
1820+
series.set("tooltip", am5.Tooltip.new(root, {
1821+
getFillFromSprite: false,
1822+
autoTextColor: false
1823+
}));
1824+
1825+
series.get("tooltip").get("background").setAll({
1826+
fill: am5.color("#343a40"),
1827+
stroke: am5.color("#6c757d"),
1828+
strokeWidth: 1,
1829+
cornerRadius: 4,
1830+
fillOpacity: 0.95
1831+
});
1832+
1833+
series.get("tooltip").label.setAll({
1834+
fill: am5.color("#ffffff"),
1835+
fontSize: "11px",
1836+
fontWeight: "500"
1837+
});
1838+
1839+
const legend = chart.children.push(am5.Legend.new(root, {
1840+
centerX: am5.percent(50),
1841+
x: am5.percent(50),
1842+
marginTop: 10,
1843+
marginBottom: 10
1844+
}));
1845+
1846+
legend.labels.template.setAll({
1847+
fontSize: "12px",
1848+
fontWeight: "500",
1849+
fill: am5.color("#ffffff")
1850+
});
1851+
1852+
legend.valueLabels.template.setAll({
1853+
fill: am5.color("#ffffff")
1854+
});
1855+
1856+
legend.markers.template.setAll({
1857+
width: 14,
1858+
height: 14
1859+
});
1860+
1861+
legend.data.setAll(series.dataItems);
1862+
1863+
if (!data || data.length === 0) {
1864+
chart.set("opacity", 0.3);
1865+
series.set("visible", false);
1866+
legend.set("visible", false);
1867+
series.data.setAll([]);
1868+
} else {
1869+
noDataLabel.set("visible", false);
1870+
chart.set("opacity", 1);
1871+
series.set("visible", true);
1872+
legend.set("visible", true);
1873+
series.data.setAll(data);
1874+
series.appear(1000, 100);
1875+
}
1876+
1877+
return chart;
1878+
}
1879+
1880+
function createRecordedFormatChart(submissions) {
1881+
if (!submissions || submissions.length === 0) {
1882+
createChart('recordFormatDist', 'Recorded Format', [], ['#4CAF50', '#F44336']);
1883+
return;
1884+
}
1885+
1886+
const formatCounts = submissions.reduce((acc, submission) => {
1887+
const format = submission.recordedFormat || 'Practised';
1888+
acc[format] = (acc[format] || 0) + 1;
1889+
return acc;
1890+
}, {});
1891+
1892+
const chartData = Object.entries(formatCounts).map(([format, count]) => ({
1893+
category: format,
1894+
count: count
1895+
}));
1896+
1897+
createChart('recordFormatDist', 'Recorded Format', chartData, ['#4CAF50', '#F44336']);
1898+
}
1899+
1900+
function createAuthorDistributionChart(submissions) {
1901+
if (!submissions || submissions.length === 0) {
1902+
createChart('mapAuthorChart', 'Map Author', [], ['#FF9800', '#2196F3']);
1903+
return;
1904+
}
1905+
1906+
const authorCounts = submissions.reduce((acc, submission) => {
1907+
const type = submission.author ? 'Yes' : 'No';
1908+
acc[type] = (acc[type] || 0) + 1;
1909+
return acc;
1910+
}, {});
1911+
1912+
const chartData = Object.entries(authorCounts).map(([type, count]) => ({
1913+
category: type,
1914+
count: count
1915+
}));
1916+
1917+
createChart('mapAuthorChart', 'Map Author', chartData, ['#FF9800', '#2196F3']);
1918+
}
1919+
1920+
function createSubmissionTypeChart(submissions) {
1921+
if (!submissions || submissions.length === 0) {
1922+
createChart('submissionTypeChart', 'Submission Distributable', [], ['#9C27B0', '#607D8B']);
1923+
return;
1924+
}
1925+
1926+
const typeCounts = submissions.reduce((acc, submission) => {
1927+
const type = !(submission.author && !submission.distributable) ? 'Yes' : 'No';
1928+
acc[type] = (acc[type] || 0) + 1;
1929+
return acc;
1930+
}, {});
1931+
1932+
const chartData = Object.entries(typeCounts).map(([type, count]) => ({
1933+
category: type,
1934+
count: count
1935+
}));
1936+
1937+
createChart('submissionTypeChart', 'Submission Distributable', chartData, ['#9C27B0', '#607D8B']);
1938+
}
1939+
1940+
createSubmissionCharts()
1941+
})())
1942+
<% } %>
16891943
});
16901944
</script>

src/services/SubmissionRoundResultService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export class SubmissionRoundResultService {
2828
allEntries = entries;
2929
} else {
3030
// get all tbe entries for this round
31-
allEntries = await this.submissionService.getAllEntries();
31+
allEntries = await this.submissionService.getAllEntries(-1, true);
3232
}
3333
if (allEntries.length === 0) {
3434
throw new BadRequest("There are no entries in this round!");

src/services/SubmissionService.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,15 +170,16 @@ export class SubmissionService {
170170
return this.submissionRepo.retrieveSubmission(id);
171171
}
172172

173-
public async getAllEntries(roundId = -1): Promise<SubmissionModel[]> {
173+
public async getAllEntries(roundId = -1, validAndVerifiedOnly = false): Promise<SubmissionModel[]> {
174174
if (roundId === -1) {
175-
const currentActiveRound = await this.submissionRoundService.getCurrentActiveSubmissionRound();
175+
const currentActiveRound =
176+
await this.submissionRoundService.getCurrentActiveSubmissionRound(validAndVerifiedOnly);
176177
if (!currentActiveRound) {
177178
throw new BadRequest("No round exists.");
178179
}
179180
return currentActiveRound.submissions;
180181
}
181-
return this.submissionRepo.getAllSubmissions(roundId);
182+
return this.submissionRepo.getAllSubmissions(roundId, validAndVerifiedOnly);
182183
}
183184

184185
public async deleteEntries(ids: number[], notify = true): Promise<boolean> {

0 commit comments

Comments
 (0)