Initial commit

This commit is contained in:
Andrew 2023-03-26 14:40:47 +07:00
commit 24f0a28a3c
25 changed files with 3190 additions and 0 deletions

892
views/admin/index.hbs Normal file
View file

@ -0,0 +1,892 @@
<style>
.chart-container {
position: relative;
margin: auto;
height: 25rem;
width: 100%;
}
</style>
<div id="root" x-data="{ loading: true }" x-init="fetchData()" x-ref="root">
<div x-show="loading" class="position-absolute spinner-border start-50 top-50" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<div x-show="!loading" x-data="{ tab: $persist('vgroups') }" x-ref="tabs-holder" x-cloak x-transition>
<header class="navbar navbar-expand-md navbar-dark bd-navbar">
<ul class="nav nav-pills container flex-wrap flex-md-nowrap gap-2">
<li class="nav-item" @click="tab = 'vgroups'">
<a :class="{ 'active': tab === 'vgroups' }" @click.prevent="tab = 'vgroups'" href="#vgroups" class="nav-link" aria-current="page" href="#vgroups">Группы</a>
</li>
<li class="nav-item" @click="tab = 'voters'">
<a :class="{ 'active': tab === 'voters' }" @click.prevent="tab = 'voters'" href="#voters" class="nav-link" href="#voters">Пользователи</a>
</li>
<li class="nav-item" @click="tab = 'votes'">
<a :class="{ 'active': tab === 'votes' }" @click.prevent="tab = 'votes'" href="#votes" class="nav-link" href="#votes">Голоса</a>
</li>
<li class="nav-item" @click="tab = 'analysis'">
<a :class="{ 'active': tab === 'analysis' }" @click.prevent="tab = 'analysis'" href="#analysis" class="nav-link" href="#analysis">Анализ</a>
</li>
<div class="flex-grow-1"></div>
<li class="nav-item">
<a class="nav-link active" href="/gateway/logout">Выйти</a>
</li>
</ul>
</header>
<div class="container p-0">
<div x-show="tab === 'vgroups'">
<form x-data="{ newModelName: '', newModelDescription: '' }" class="mb-4 d-flex flex-row gap-2" action="/admin/vgroups/create" method="POST">
<input type="text" x-model="newModelName" name="name" class="form-control form-control-sm flex-grow-1" placeholder="Название">
<input type="text" x-model="newModelDescription" name="description" class="form-control form-control-sm flex-grow-1" placeholder="Описание">
<button x-bind:disabled="newModelName.length < 3" class="btn btn-info btn-sm w-50">Создать новую группу</button>
</form>
<table x-data="{ titleSearch: '' }" id="vgroupsTable" class="w-100 table table-sm table-bordered">
<thead>
<tr>
<th scope="col" class="align-middle text-center">ID</th>
<th scope="col" class="align-middle text-start">
Название
<input type="text" class="form-control form-control-sm" placeholder="Поиск"
@input="titleSearch = $event.target.value;">
</th>
<th scope="col" class="align-middle text-center">Описание</th>
<th scope="col" class="align-middle text-center">Действия</th>
</tr>
</thead>
<tbody class="table-group-divider">
<template x-if="!$store.dataset.vgroups">
<tr><td colspan="4" scope="row"><i>Loading...</i></td></tr>
</template>
<template x-for="vgroup in $store.dataset.vgroups">
<tr x-show="vgroup.name.includes(titleSearch)">
<td x-text="vgroup.id" scope="row"></td>
<td x-text="vgroup.name"></td>
<td x-text="vgroup.description || '[Нет описания]'"></td>
<td>
<button class="btn btn-sm btn-outline-danger"
@click="deleteVgroup(vgroup.id)">
Удалить
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div x-show="tab === 'voters'">
<form x-data="{ newVoterFullName: '', newVoterLogin: '', newVoterPassword: '' }" class="mb-4 d-flex flex-row gap-2" action="/admin/voters/create" method="POST">
<input type="text" x-model="newVoterFullName" name="full_name" class="form-control form-control-sm flex-grow-1" placeholder="Полное имя">
<input type="text" x-model="newVoterLogin" name="login" class="form-control form-control-sm flex-grow-1" placeholder="Логин">
<input type="text" x-model="newVoterPassword" name="password" class="form-control form-control-sm flex-grow-1" placeholder="Пароль">
<button x-bind:disabled="newVoterFullName.length < 4 || newVoterLogin.length < 5 || newVoterPassword.length < 4" class="btn btn-info btn-sm w-50">Создать пользователя</button>
</form>
<table x-data="{ groupFilter: -1 }" id="votersTable" class="w-100 table table-sm table-bordered">
<thead>
<tr>
<th scope="col" class="align-middle text-center">ID</th>
<th scope="col" class="align-middle text-center">Имя</th>
<th scope="col" class="align-middle text-center">Логин</th>
<th scope="col" class="align-middle text-start">
Группа
<select class="form-select form-select-sm" @change="groupFilter = $event.target.value">
<option value="-1" selected>Без фильтра</option>
<template x-for="vgroup in $store.dataset.vgroups">
<option x-bind:value="vgroup.id" x-text="vgroup.name"></option>
</template>
</select>
</th>
<th scope="col" class="align-middle text-center">Действия</th>
</tr>
</thead>
<tbody class="table-group-divider">
<template x-if="!$store.dataset.voters">
<tr><td colspan="5" scope="row"><i>Loading...</i></td></tr>
</template>
<template x-for="voter in $store.dataset.voters">
<tr x-show="groupFilter == -1 || groupFilter == voter.vgroup_id">
<td x-text="voter.id" scope="row"></td>
<td x-text="voter.full_name"></td>
<td x-text="voter.login"></td>
<td>
<template x-if="!$store.dataset.vgroups">
<tr><td colspan="4" scope="row"><i>Loading...</i></td></tr>
</template>
<select class="form-select form-select-sm"
@change="updateUserGroup(voter.id, $event.target.value)">
<option value="null" x-bind:selected="voter.vgroup_id === null">Не привязан</option>
<template x-for="vgroup in $store.dataset.vgroups">
<option x-bind:value="vgroup.id" x-text="vgroup.name" x-bind:selected="voter.vgroup_id === vgroup.id"></option>
</template>
</select>
</td>
<td>
<button class="btn btn-sm btn-outline-danger"
@click="deleteVoter(voter.id)">
Удалить
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div x-show="tab === 'votes'" x-data="{ colorCoded: false }">
<div class="form-check form-switch form-check-reverse">
<input class="form-check-input" type="checkbox" id="flexSwitchCheckReverse" @change="colorCoded = $event.target.checked">
<label class="form-check-label" for="flexSwitchCheckReverse">Колор-кодинг</label>
</div>
<table x-data="{ groupFilter: -1, dateFilter: '' }" id="votesTable" class="w-100 table table-sm table-bordered">
<thead>
<tr>
<th scope="col" class="align-middle text-center">
Когда
<select class="form-select form-select-sm"
@change="dateFilter = $event.target.value">
<option value="" selected>Без фильтра</option>
<template x-for="voteDate in getDatesFromDatasets()">
<option x-bind:value="voteDate" x-text="voteDate"></option>
</template>
</select>
</th>
<th scope="col" class="align-middle text-center">Кто</th>
<th scope="col" class="align-middle text-start">
Группа
<select class="form-select form-select-sm" @change="groupFilter = $event.target.value">
<option value="-1" selected>Без фильтра</option>
<template x-for="vgroup in $store.dataset.vgroups">
<option x-bind:value="vgroup.id" x-text="vgroup.name"></option>
</template>
</select>
</th>
<th scope="col" class="align-middle text-center wm-vlr">Здоровье</th>
<th scope="col" class="align-middle text-center wm-vlr">Любовь</th>
<th scope="col" class="align-middle text-center wm-vlr">Секс</th>
<th scope="col" class="align-middle text-center wm-vlr">Работа</th>
<th scope="col" class="align-middle text-center wm-vlr">Отдых</th>
<th scope="col" class="align-middle text-center wm-vlr">Деньги</th>
<th scope="col" class="align-middle text-center wm-vlr">Отношения</th>
<th scope="col" class="align-middle text-center wm-vlr">Личн. рост</th>
<th scope="col" class="align-middle text-center wm-vlr">Смысл жизни</th>
<th scope="col" class="align-middle text-center wm-vlr">Тревожность</th>
<th scope="col" class="align-middle text-center">Действия</th>
</tr>
</thead>
<tbody class="table-group-divider">
<template x-if="!$store.dataset.votes">
<tr><td colspan="14" scope="row"><i>Loading...</i></td></tr>
</template>
<template x-for="vote in $store.dataset.votes">
<tr
x-data="{ voter: $store.dataset.voters.find((el) => el.id == vote.voter_id), vfd: formatDateMMYYYY(new Date(vote.vote_date)) }"
x-show="(groupFilter == -1 || groupFilter == voter.vgroup_id) && (vfd.includes(dateFilter))">
<td x-text="vfd" scope="row"></td>
<td x-text="voter?.full_name || '[Нет имени]'"></td>
<td x-text="getVGroupNameById(voter.vgroup_id)"></td>
<td class="fading-bg stats-cell" x-text="vote.health"
:class="{
'status-ok': colorCoded && vote.health >= 7,
'status-warning': colorCoded && vote.health < 7 && vote.health > 4,
'status-dangerous': colorCoded && vote.health <= 4
}" scope="row"></td>
<td class="fading-bg stats-cell" x-text="vote.love"
:class="{
'status-ok': colorCoded && vote.love >= 7,
'status-warning': colorCoded && vote.love < 7 && vote.love > 4,
'status-dangerous': colorCoded && vote.love <= 4
}" scope="row"></td>
<td class="fading-bg stats-cell" x-text="vote.sex"
:class="{
'status-ok': colorCoded && vote.sex >= 7,
'status-warning': colorCoded && vote.sex < 7 && vote.sex > 4,
'status-dangerous': colorCoded && vote.sex <= 4
}" scope="row"></td>
<td class="fading-bg stats-cell" x-text="vote.work"
:class="{
'status-ok': colorCoded && vote.work >= 7,
'status-warning': colorCoded && vote.work < 7 && vote.work > 4,
'status-dangerous': colorCoded && vote.work <= 4
}" scope="row"></td>
<td class="fading-bg stats-cell" x-text="vote.rest"
:class="{
'status-ok': colorCoded && vote.rest >= 7,
'status-warning': colorCoded && vote.rest < 7 && vote.rest > 4,
'status-dangerous': colorCoded && vote.rest <= 4
}" scope="row"></td>
<td class="fading-bg stats-cell" x-text="vote.finances"
:class="{
'status-ok': colorCoded && vote.finances >= 7,
'status-warning': colorCoded && vote.finances < 7 && vote.finances > 4,
'status-dangerous': colorCoded && vote.finances <= 4
}" scope="row"></td>
<td class="fading-bg stats-cell" x-text="vote.relations"
:class="{
'status-ok': colorCoded && vote.relations >= 7,
'status-warning': colorCoded && vote.relations < 7 && vote.relations > 4,
'status-dangerous': colorCoded && vote.relations <= 4
}" scope="row"></td>
<td class="fading-bg stats-cell" x-text="vote.pers_growth"
:class="{
'status-ok': colorCoded && vote.pers_growth >= 7,
'status-warning': colorCoded && vote.pers_growth < 7 && vote.pers_growth > 4,
'status-dangerous': colorCoded && vote.pers_growth <= 4
}" scope="row"></td>
<td class="fading-bg stats-cell" x-text="vote.meaning_of_life"
:class="{
'status-ok': colorCoded && vote.meaning_of_life >= 7,
'status-warning': colorCoded && vote.meaning_of_life < 7 && vote.meaning_of_life > 4,
'status-dangerous': colorCoded && vote.meaning_of_life <= 4
}" scope="row"></td>
<td class="fading-bg stats-cell" x-text="vote.serenity"
:class="{
'status-ok': colorCoded && vote.serenity <= 4,
'status-warning': colorCoded && vote.serenity > 4 && vote.serenity < 7,
'status-dangerous': colorCoded && vote.serenity >= 7
}" scope="row"></td>
<td>
<button class="btn btn-sm btn-outline-danger"
@click="deleteVote(vote.id)">
Удалить
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div x-show="tab === 'analysis'" x-data="{
show_acloub: $persist(true),
show_agc: $persist(true),
shown_agac: $persist(true),
shown_gc: $persist(true),
shown_cat: $persist(true),
}">
<div class="align-items-center d-flex flex-row justify-content-between">
<h4>Среднее за клуб</h4>
<button class="badge text-bg-secondary"
@click="show_acloub = !show_acloub"
x-text="show_acloub? 'Скрыть' : 'Показать'"></button>
</div>
<div x-show="show_acloub" x-transition>
<div class="chart-container">
<canvas chart-id="clubAverageCategories" width="400" height="200"></canvas>
</div>
</div>
<hr>
<div class="align-items-center d-flex flex-row justify-content-between">
<h4>Среднее по категориям в группах</h4>
<button class="badge text-bg-secondary"
@click="show_agc = !show_agc"
x-text="show_agc? 'Скрыть' : 'Показать'"></button>
</div>
<div x-show="show_agc" x-transition>
<div class="m-0 mb-1 mb-2 row">
<select class="form-select form-select-sm col me-1"
@change="$store.dataset.selectedGacavVgroup = parseInt($event.target.value); updateGACChart()">
<template x-for="vgroup_id in Object.keys($store.dataset.grouppedVotes)">
<option x-bind:value="vgroup_id" x-text="getVGroupNameById(vgroup_id)" x-bind:selected="$store.dataset.selectedGacavVgroup === vgroup_id"></option>
</template>
</select>
</div>
<div class="chart-container">
<canvas chart-id="groupAverageCategories" width="400" height="200"></canvas>
</div>
</div>
<hr>
<div class="align-items-center d-flex flex-row justify-content-between">
<h4>Среднее за по категориям среди всех групп</h4>
<button class="badge text-bg-secondary"
@click="shown_agac = !shown_agac"
x-text="shown_agac? 'Скрыть' : 'Показать'"></button>
</div>
<div x-show="shown_agac" x-transition>
<div class="m-0 mb-1 mb-2 row">
<select class="form-select form-select-sm col ms-1"
@change="$store.dataset.agacVoteDate = $event.target.value; updateAGACChart()">
<template x-for="voteDate in getDatesFromDatasets()">
<option x-bind:value="voteDate" x-text="voteDate" x-bind:selected="$store.dataset.agacVoteDate === voteDate"></option>
</template>
</select>
</div>
<div class="chart-container">
<canvas chart-id="allGroupAverageCategories" width="400" height="200"></canvas>
</div>
</div>
<hr>
<div class="align-items-center d-flex flex-row justify-content-between">
<h4>Значения в группе по категориям за дату</h4>
<button class="badge text-bg-secondary"
@click="shown_gc = !shown_gc"
x-text="shown_gc? 'Скрыть' : 'Показать'"></button>
</div>
<div x-show="shown_gc" x-transition>
<div class="m-0 mb-1 mb-2 row">
<select class="form-select form-select-sm col me-1"
@change="$store.dataset.selectedRadarVgroup = parseInt($event.target.value); updateRadarChart()">
<template x-for="vgroup_id in Object.keys($store.dataset.grouppedVotes)">
<option x-bind:value="vgroup_id" x-text="getVGroupNameById(vgroup_id)" x-bind:selected="$store.dataset.selectedRadarVgroup === vgroup_id"></option>
</template>
</select>
<select class="form-select form-select-sm col ms-1"
@change="$store.dataset.voteDate = $event.target.value; updateRadarChart()">
<template x-for="voteDate in getDatesFromDatasets()">
<option x-bind:value="voteDate" x-text="voteDate" x-bind:selected="$store.dataset.voteDate === voteDate"></option>
</template>
</select>
</div>
<div id="lowDatasetCountNotification" class="alert alert-info" role="alert"
x-show="$store.dataset.grouppedVotes[$store.dataset.selectedRadarVgroup] && !$store.dataset.grouppedVotes[$store.dataset.selectedRadarVgroup].find(e => e.vote_date == $store.dataset.voteDate)"
x-intersect:enter="$store.dynamicsChart = 'bar'">
У группы нет данных за эту дату
</div>
<div class="chart-container">
<canvas chart-id="groupCategories" width="400" height="200"></canvas>
</div>
</div>
<hr>
<div class="align-items-center d-flex flex-row justify-content-between">
<h4>Динамика категорий группы во времени (среднее)</h4>
<button class="badge text-bg-secondary"
@click="shown_cat = !shown_cat"
x-text="shown_cat? 'Скрыть' : 'Показать'"></button>
</div>
<div x-show="shown_cat" x-transition>
<div class="m-0 mb-1 mb-2 row">
<select class="form-select form-select-sm col"
@change="$store.dataset.selectedVgroup = parseInt($event.target.value); updateLineChart()">
<template x-for="vgroup_id in Object.keys($store.dataset.grouppedVotes)">
<option x-bind:value="vgroup_id" x-text="getVGroupNameById(vgroup_id)" x-bind:selected="$store.dataset.selectedVgroup === vgroup_id"></option>
</template>
</select>
</div>
<div id="lowDatasetCountNotification" class="alert alert-info" role="alert"
x-show="$store.dataset.grouppedVotes[$store.dataset.selectedVgroup] && $store.dataset.grouppedVotes[$store.dataset.selectedVgroup].length == 1"
x-intersect:enter="$store.dynamicsChart = 'bar'">
Мало данных по группе, лучше использовать столбчатую диаграмму
</div>
<div class="chart-container">
<canvas chart-id="categoryBars" width="400" height="300"></canvas>
</div>
</div>
<div class="pb-5 pt-5"></div>
</div>
</div>
</div>
</div>
<script>
const randomNum = () => Math.floor(Math.random() * (235 - 52 + 1) + 52);
const randomRGB = () => `rgba(${randomNum()}, ${randomNum()}, ${randomNum()}, 0.5)`;
const randomRGBO = (opacity) => `rgba(${randomNum()}, ${randomNum()}, ${randomNum()}, ${opacity})`;
const randomRGBV = () => [randomNum(), randomNum(), randomNum()];
const average = (arr) => (arr.reduce((acc, c) => acc + c, 0) / arr.length);
const fetchDatasetCategory = (dataset, group, category) => dataset[group].map(e => average(e[category]));
const getVGroupNameById = (vgroup_id) => Alpine.store("dataset").vgroups.find((el) => el.id == vgroup_id)?.name || '[Нет группы]';
const getDatesFromDatasets = () => [...new Set(Object.values(Alpine.store("dataset").grouppedVotes).map(e => e.map(i => i.vote_date)).reduce((acc, v) => [...acc, ...v], []))].sort();
const formatDateMMYYYY = (date) => date.toLocaleString('ru', { month: "numeric", year: "numeric" });
const getColorBasedOnValue = (value) => {
if (value <= 4) return 'rgba(255, 0, 0, 0.5)';
if (value > 4 && value < 7) return 'rgba(255, 255, 0, 0.5)';
return 'rgba(0, 255, 0, 0.5)';
};
</script>
<script src="/javascripts/chart.min.js"></script>
<script src="/javascripts/chartjs-plugin-datalabels-v2.1.0.js"></script>
<script>
const dbToCategory = {
health: "Здоровье",
love: "Любовь",
sex: "Секс",
rest: "Отдых",
finances: "Деньги",
meaning_of_life: "Смысл жизни",
serenity: "Тревожность",
relations: "Отношения",
pers_growth: "Личн. рост",
work: "Работа",
};
class ChartsController {
constructor() {
this.charts = {};
}
createChart(id, type, data, options) {
const ctx = document.querySelector(`[chart-id=${id}]`).getContext('2d');
this.charts[id] = {
ctx,
chart: new Chart(ctx, {
type: type,
data: data,
options: options,
})
};
}
updateChartData(id, labels, data) {
this.charts[id].chart.data.labels.length = 0;
this.charts[id].chart.data.datasets.length = 0;
this.charts[id].chart.data.labels.push(...labels);
this.charts[id].chart.data.datasets.push(...data);
this.charts[id].chart.update();
}
}
</script>
<script>
Chart.register(ChartDataLabels);
Chart.defaults.set("plugins.datalabels", {display: false});
const ct = new ChartsController();
let rootElement;
document.addEventListener("DOMContentLoaded", function() {
rootElement = document.getElementById("root");
});
document.addEventListener('alpine:init', () => {
Alpine.store("dataset", {
vgroups: [],
voters: [],
votes: [],
grouppedVotes: {},
});
ct.createChart("clubAverageCategories", "radar", {}, {
maintainAspectRatio: false,
plugins: {},
scales: {
v: {
min: 0,
max: 10,
axis: "r",
},
}
});
ct.createChart("groupAverageCategories", "radar", {}, {
maintainAspectRatio: false,
plugins: {},
scales: {
v: {
min: 0,
max: 10,
axis: "r",
pointLabels: {
display: true,
centerPointLabels: true,
},
},
}
});
ct.createChart("allGroupAverageCategories", "polarArea", {}, {
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
},
scales: {
v: {
min: 0,
max: 10,
axis: "r",
pointLabels: {
display: true,
centerPointLabels: true,
},
},
}
});
ct.createChart("groupCategories", "polarArea", {}, {
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
},
scales: {
v: {
min: 0,
max: 10,
axis: "r",
pointLabels: {
display: true,
centerPointLabels: true,
},
},
}
});
ct.createChart("categoryBars", "bar", {}, {
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
datalabels: {
display: true,
color: 'black',
font: {
size: 14
},
rotation: -90,
align: 'end',
anchor: 'end',
clip: true,
display: 'auto',
formatter: function(value, context) {
return context.dataset.label;
},
}
},
scales: {
y: {
min: 0,
max: 12,
},
x: {
min: 0,
max: 10,
}
}
});
});
const fetchData = async function() {
Alpine.store("dataset").vgroups = [];
Alpine.store("dataset").voters = [];
Alpine.store("dataset").votes = [];
Alpine.store("dataset").grouppedVotes = {};
try {
let resp;
resp = await fetch("/admin/vgroups");
Alpine.store("dataset").vgroups = await resp.json();
resp = await fetch("/admin/voters");
Alpine.store("dataset").voters = await resp.json();
resp = await fetch("/admin/votes");
Alpine.store("dataset").votes = await resp.json();
resp = await fetch("/admin/votes/groupped");
const data = await resp.json();
data.forEach((e) => {
Alpine.store("dataset").grouppedVotes[e.vgroup_id] = [...(Alpine.store("dataset").grouppedVotes[e.vgroup_id] || []), e];
});
try {
const vg = Object.keys(Alpine.store("dataset").grouppedVotes)[0];
Alpine.store("dataset").selectedVgroup = vg;
Alpine.store("dataset").selectedRadarVgroup = vg;
Alpine.store("dataset").selectedGacavVgroup = vg;
const ad = getDatesFromDatasets()[0];
Alpine.store("dataset").voteDate = ad;
Alpine.store("dataset").agacVoteDate = ad;
updateCACChart();
updateGACChart();
updateAGACChart();
updateAGACChart();
updateLineChart();
updateRadarChart();
} catch (e) {
alert("Произошла ошибка выделения данных для графиков, обратитесь к разработчику");
console.error(e);
}
} catch (e) {
alert("Ошибка загрузки данных");
console.error(e);
}
Alpine.$data(rootElement).loading = false;
}
const deleteVgroup = async function (vgroupId) {
try {
const resp = await fetch(`/admin/vgroups/${vgroupId}/delete`);
const json = await resp.json();
if (json.error) {
alert(json.error);
}
} catch (e) {
alert(e.message);
}
fetchData();
}
const updateUserGroup = async function (userId, newGroup) {
try {
const resp = await fetch(`/admin/voters/${userId}/promote/${newGroup}`);
const json = await resp.json();
if (json.error) {
alert(json.error);
}
} catch (e) {
alert(e.message);
}
fetchData();
}
const deleteVoter = async function (voterId) {
try {
const resp = await fetch(`/admin/voters/${voterId}/delete`);
const json = await resp.json();
if (json.error) {
alert(json.error);
}
} catch (e) {
alert(e.message);
}
fetchData();
}
const deleteVote = async function (voteId) {
try {
const resp = await fetch(`/admin/votes/${voteId}/delete`);
const json = await resp.json();
if (json.error) {
alert(json.error);
}
} catch (e) {
alert(e.message);
}
fetchData();
}
const updateCACChart = function () {
const baseDataset = Alpine.store("dataset").grouppedVotes;
const combinedDataset = {};
Object.values(Alpine.store("dataset").grouppedVotes).forEach(e => {
const rds = e.reduce((acc, v) => {
acc[v.vote_date] ??= {};
Object.keys(dbToCategory).forEach(key => {
acc[v.vote_date][key] ??= [];
acc[v.vote_date][key].push(...v[key])
});
return acc;
}, {});
Object.keys(rds).forEach(key => {
combinedDataset[key] ??= {};
Object.keys(rds[key]).forEach(kv => {
combinedDataset[key][kv] ??= [];
combinedDataset[key][kv].push(...rds[key][kv]);
});
});
}, {});
console.log(combinedDataset);
const labels = Object.keys(dbToCategory).map(key => dbToCategory[key]);
const datasets = Object.keys(combinedDataset).map(voteDate => {
const avgs = Object.keys(dbToCategory).map(key => average(combinedDataset[voteDate][key]));
const d = {
label: voteDate,
backgroundColor: randomRGBO(0.2), // FIIXME: randomize
pointBackgroundColor: avgs.map(getColorBasedOnValue),
borderWidth: 2,
data: avgs,
};
return d;
});
ct.updateChartData("clubAverageCategories", labels, datasets);
}
const updateGACChart = function () {
const [baseDataset, group] = [
Alpine.store("dataset").grouppedVotes,
Alpine.store("dataset").selectedGacavVgroup,
];
const labels = Object.keys(dbToCategory).map(key => dbToCategory[key]);
const datasets = baseDataset[group].map(e => {
const avgs = Object.keys(dbToCategory).map(key => e[key]).map(average);
const d = {
label: e.vote_date,
backgroundColor: randomRGBO(0.2), // FIIXME: randomize
pointBackgroundColor: avgs.map(getColorBasedOnValue),
borderWidth: 2,
data: avgs,
};
return d;
});
ct.updateChartData("groupAverageCategories", labels, datasets);
}
const update__Chart = function () {
const [baseDataset, voteDate] = [
Alpine.store("dataset").grouppedVotes,
Alpine.store("dataset").agacVoteDate,
];
const labels = [];
const datasets = [];
Object.keys(gvotes).forEach(cat => {
labels.push(dbToCategory[cat]);
// colors.push(getColorBasedOnValue(cat == "serenity" ? 10-av : av));
});
ct.updateChartData("allGroupAverageCategories", labels, datasets);
}
const updateAGACChart = function() {
const [baseDataset, voteDate] = [
Alpine.store("dataset").grouppedVotes,
Alpine.store("dataset").agacVoteDate,
];
const datasets = [];
const gvotes = {};
Object.values(baseDataset).forEach(vg => {
vg.forEach(ds => {
if (ds.vote_date === voteDate) {
Object.keys(dbToCategory).forEach(cat => {
const av = average(ds[cat]);
gvotes[cat] ??= [];
gvotes[cat].push(av);
});
}
});
});
const labels = [];
const colors = [];
const vals = [];
Object.keys(gvotes).forEach(cat => {
const av = average(gvotes[cat]);
labels.push(dbToCategory[cat]);
colors.push(getColorBasedOnValue(cat == "serenity" ? 10-av : av));
vals.push(av);
});
datasets.push({
label: labels,
backgroundColor: colors,
borderWidth: 2,
data: vals,
});
ct.updateChartData("allGroupAverageCategories", labels, datasets);
}
const updateRadarChart = function() {
const [baseDataset, group, voteDate] = [
Alpine.store("dataset").grouppedVotes,
Alpine.store("dataset").selectedRadarVgroup,
Alpine.store("dataset").voteDate,
];
const groupDataset = baseDataset[group].find(e => e.vote_date === voteDate);
const labels = [];
const datasets = [];
if (groupDataset) {
const vals = [];
Object.keys(dbToCategory).forEach(k => {
labels.push(dbToCategory[k]);
vals.push(average(groupDataset[k]));
});
const dataset = {
label: getVGroupNameById(group),
backgroundColor: vals.map((v, i) => getColorBasedOnValue(labels[i] == "Тревожность" ? 10-v : v)),
borderWidth: 2,
data: vals
};
datasets.push(dataset);
}
ct.updateChartData("groupCategories", labels, datasets);
}
const updateLineChart = function() {
const [baseDataset, group] = [Alpine.store("dataset").grouppedVotes, Alpine.store("dataset").selectedVgroup];
const labels = baseDataset[group].map(e => e.vote_date);
const datasets = [
{
label: "Здоровье",
backgroundColor: fetchDatasetCategory(baseDataset, group, "health").map(getColorBasedOnValue),
borderWidth: 2,
data: fetchDatasetCategory(baseDataset, group, "health")
},
{
label: "Любовь",
backgroundColor: fetchDatasetCategory(baseDataset, group, "love").map(getColorBasedOnValue),
borderWidth: 2,
data: fetchDatasetCategory(baseDataset, group, "love")
},
{
label: "Секс",
backgroundColor: fetchDatasetCategory(baseDataset, group, "sex").map(getColorBasedOnValue),
borderWidth: 2,
data: fetchDatasetCategory(baseDataset, group, "sex")
},
{
label: "Отдых",
backgroundColor: fetchDatasetCategory(baseDataset, group, "rest").map(getColorBasedOnValue),
borderWidth: 2,
data: fetchDatasetCategory(baseDataset, group, "rest")
},
{
label: "Деньги",
backgroundColor: fetchDatasetCategory(baseDataset, group, "finances").map(getColorBasedOnValue),
borderWidth: 2,
data: fetchDatasetCategory(baseDataset, group, "finances")
},
{
label: "Смысл жизни",
backgroundColor: fetchDatasetCategory(baseDataset, group, "meaning_of_life").map(getColorBasedOnValue),
borderWidth: 2,
data: fetchDatasetCategory(baseDataset, group, "meaning_of_life")
},
{
label: "Тревожность",
backgroundColor: fetchDatasetCategory(baseDataset, group, "serenity").map(e => getColorBasedOnValue(10-e)),
borderWidth: 2,
data: fetchDatasetCategory(baseDataset, group, "serenity")
},
{
label: "Отношения",
backgroundColor: fetchDatasetCategory(baseDataset, group, "relations").map(getColorBasedOnValue),
borderWidth: 2,
data: fetchDatasetCategory(baseDataset, group, "relations")
},
{
label: "Личн. рост",
backgroundColor: fetchDatasetCategory(baseDataset, group, "pers_growth").map(getColorBasedOnValue),
borderWidth: 2,
data: fetchDatasetCategory(baseDataset, group, "pers_growth")
},
{
label: "Работа",
backgroundColor: fetchDatasetCategory(baseDataset, group, "work").map(getColorBasedOnValue),
borderWidth: 2,
data: fetchDatasetCategory(baseDataset, group, "work")
}
];
ct.updateChartData("categoryBars", labels, datasets);
}
</script>

16
views/error.hbs Normal file
View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<symbol id="exclamation-triangle-fill" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</symbol>
</svg>
<main class="align-items-center container d-flex flex-row h-screen justify-content-center">
<div class="alert alert-danger" role="alert">
<h4 class="alert-heading">{{message}}</h4>
<p>{{error.status}}</p>
<a href="/" class="alert-link">Вернуться на главную</a>
<!--
{{error.stack}}
-->
</div>
</main>

After

Width:  |  Height:  |  Size: 783 B

35
views/gateway/index.hbs Normal file
View file

@ -0,0 +1,35 @@
<main class="align-items-center container d-flex flex-row h-screen justify-content-center">
<div class="flex-grow-1 h-fit-content">
<h2>Вход</h2>
<form action="/gateway/login" method="POST">
<div class="mb-3">
<label for="login" class="form-label">Логин</label>
<input type="text" id="login" name="login" class="form-control" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Пароль</label>
<input type="password" id="password" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary">Войти</button>
</form>
</div>
<div class="flex-grow-0 p-4 h-fit-content">или</div>
<div class="flex-grow-1 h-fit-content">
<h2>Регистрация</h2>
<form action="/gateway/register" method="POST">
<div class="mb-3">
<label for="full-name-reg" class="form-label">Как вас зовут</label>
<input type="text" id="full-name-reg" name="full_name" class="form-control" required>
</div>
<div class="mb-3">
<label for="login-reg" class="form-label">Логин</label>
<input type="text" id="login-reg" name="login" class="form-control" required>
</div>
<div class="mb-3">
<label for="password-reg" class="form-label">Пароль</label>
<input type="password" id="password-reg" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary">Зарегистрироваться</button>
</form>
</div>
</main>

26
views/layout.hbs Normal file
View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<title>{{title}}</title>
<script src="//unpkg.com/@alpinejs/persist" defer></script>
<script src="//unpkg.com/@alpinejs/intersect" defer></script>
<script src="//unpkg.com/alpinejs" defer></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="/stylesheets/style.css" />
</head>
<body>
{{#if flashes}}
<div class="container position-absolute start-50 top-0 translate-middle-x pt-2" style="z-index: 5;">
{{#each flashes}}
<div class="alert alert-info alert-dismissible fade show" role="alert">
{{this}}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{{/each}}
</div>
{{/if}}
{{{body}}}
</body>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js"></script>
</html>

410
views/userspace/index.hbs Normal file
View file

@ -0,0 +1,410 @@
<div class="h-screen d-flex flex-column">
<header class="navbar navbar-expand-md navbar-dark bd-navbar">
<ul class="nav nav-pills container flex-wrap flex-md-nowrap gap-2">
<h4>Привет, {{user.full_name}}!</h4>
<div class="flex-grow-1"></div>
<li class="nav-item">
<a class="nav-link active" href="/userspace/votes">История</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/gateway/logout">Выйти</a>
</li>
</ul>
</header>
<div x-data="{loading: false}" class="align-items-center container d-flex flex-grow-1 flex-row justify-content-center">
{{#if user.vgroup_id includeZero=true}}
<canvas id="wheel" style="height: 600"></canvas>
<form class="col m-4 h-fit-content">
<div class="d-flex flex-row gap-2">
<label class="w-15">Здоровье</label>
<input x-bind:disabled="loading" type="range" id="a0" class="flex-grow-1" min="0" max="10" step="1" value="0" oninput="paramChange(this, 0)">
<input x-bind:disabled="loading" type="number" class="dubler" id="b0" min="0" max="10" step="1" value="0" oninput="param2Change(this, 0)">
</div>
<div class="d-flex flex-row gap-2">
<label class="w-15">Любовь</label>
<input x-bind:disabled="loading" type="range" id="a1" class="flex-grow-1" min="0" max="10" step="1" value="0" oninput="paramChange(this, 1)">
<input x-bind:disabled="loading" type="number" class="dubler" id="b1" min="0" max="10" step="1" value="0" oninput="param2Change(this, 1)">
</div>
<div class="d-flex flex-row gap-2">
<label class="w-15">Секс</label>
<input x-bind:disabled="loading" type="range" id="a2" class="flex-grow-1" min="0" max="10" step="1" value="0" oninput="paramChange(this, 2)">
<input x-bind:disabled="loading" type="number" class="dubler" id="b2" min="0" max="10" step="1" value="0" oninput="param2Change(this, 2)">
</div>
<div class="d-flex flex-row gap-2">
<label class="w-15">Работа</label>
<input x-bind:disabled="loading" type="range" id="a3" class="flex-grow-1" min="0" max="10" step="1" value="0" oninput="paramChange(this, 3)">
<input x-bind:disabled="loading" type="number" class="dubler" id="b3" min="0" max="10" step="1" value="0" oninput="param2Change(this, 3)">
</div>
<div class="d-flex flex-row gap-2">
<label class="w-15">Отдых</label>
<input x-bind:disabled="loading" type="range" id="a4" class="flex-grow-1" min="0" max="10" step="1" value="0" oninput="paramChange(this, 4)">
<input x-bind:disabled="loading" type="number" class="dubler" id="b4" min="0" max="10" step="1" value="0" oninput="param2Change(this, 4)">
</div>
<div class="d-flex flex-row gap-2">
<label class="w-15">Деньги</label>
<input x-bind:disabled="loading" type="range" id="a5" class="flex-grow-1" min="0" max="10" step="1" value="0" oninput="paramChange(this, 5)">
<input x-bind:disabled="loading" type="number" class="dubler" id="b5" min="0" max="10" step="1" value="0" oninput="param2Change(this, 5)">
</div>
<div class="d-flex flex-row gap-2">
<label class="w-15">Отношения</label>
<input x-bind:disabled="loading" type="range" id="a6" class="flex-grow-1" min="0" max="10" step="1" value="0" oninput="paramChange(this, 6)">
<input x-bind:disabled="loading" type="number" class="dubler" id="b6" min="0" max="10" step="1" value="0" oninput="param2Change(this, 6)">
</div>
<div class="d-flex flex-row gap-2">
<label class="w-15">Личн. рост</label>
<input x-bind:disabled="loading" type="range" id="a7" class="flex-grow-1" min="0" max="10" step="1" value="0" oninput="paramChange(this, 7)">
<input x-bind:disabled="loading" type="number" class="dubler" id="b7" min="0" max="10" step="1" value="0" oninput="param2Change(this, 7)">
</div>
<div class="d-flex flex-row gap-2">
<label class="w-15">Смысл жизни</label>
<input x-bind:disabled="loading" type="range" id="a8" class="flex-grow-1" min="0" max="10" step="1" value="0" oninput="paramChange(this, 8)">
<input x-bind:disabled="loading" type="number" class="dubler" id="b8" min="0" max="10" step="1" value="0" oninput="param2Change(this, 8)">
</div>
<div class="d-flex flex-row gap-2">
<label class="w-15">Тревожность</label>
<input x-bind:disabled="loading" type="range" id="a9" class="flex-grow-1" min="0" max="10" step="1" value="0" oninput="paramChange(this, 9)">
<input x-bind:disabled="loading" type="number" class="dubler" id="b9" min="0" max="10" step="1" value="0" oninput="param2Change(this, 9)">
</div>
<button x-bind:disabled="loading" type="button" id="sendBtn" class="btn btn-outline-primary w-100 pt-2">Отправить</button>
</form>
{{else}}
<div class="text-center">
<h1>Вы не состоите в группе</h1>
<p>Повторите попытку, когда администратор привяжет вас к группе</p>
</div>
{{/if}}
</div>
</div>
{{#if user.vgroup_id includeZero=true}}
<script>
let myCanvas, ctx;
let param = [{
name: "ЗДОРОВЬЕ",
dbmap: "health",
value: 0
},
{
name: "ЛЮБОВЬ",
dbmap: "love",
value: 0
},
{
name: "СЕКС",
dbmap: "sex",
value: 0
},
{
name: "РАБОТА",
dbmap: "work",
value: 0
},
{
name: "ОТДЫХ",
dbmap: "rest",
value: 0
},
{
name: "ДЕНЬГИ",
dbmap: "finances",
value: 0
},
{
name: "ОТНОШЕНИЯ",
dbmap: "relations",
value: 0
},
{
name: "ЛИЧН. РОСТ",
dbmap: "pers_growth",
value: 0
},
{
name: "СМЫСЛ ЖИЗНИ",
dbmap: "meaning_of_life",
value: 0
},
{
name: "СПОКОЙСТВИЕ",
dbmap: "serenity",
value: 0
},
];
document.addEventListener("DOMContentLoaded", () => {
myCanvas = document.getElementById("wheel");
myCanvas.width = 480;
myCanvas.height = 600;
ctx = myCanvas.getContext("2d");
drawWheel();
const sendBtn = document.getElementById("sendBtn");
sendBtn.addEventListener("click", async () => {
Alpine.$data(sendBtn).loading = true;
try {
let res = await fetch(`${location.href}/vote`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(param),
});
let json = await res.json();
console.log(json);
if (json.status == "ok") {
alert("Спасибо за голосование!");
} else {
alert(("Ошибка голосования!\n" + (json.message || "")).trim());
console.log(json);
}
} catch (e) {
console.error(e.message);
alert(e.message);
return;
}
resetWheel();
Alpine.$data(sendBtn).loading = false;
location.reload();
});
});
const resetWheel = () => {
document.querySelectorAll(".dubler").forEach((el) => {
el.value = 0;
});
document.querySelector(".dubler").dispatchEvent(new Event("input"));
}
// отрисовка линии
function drawLine(ctx, startX, startY, endX, endY) {
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
ctx.stroke();
}
// отрисовка дуги
function drawArc(ctx, centerX, centerY, radius, startAngle, endAngle) {
ctx.beginPath();
ctx.arc(centerX, centerY, radius, startAngle, endAngle);
ctx.stroke();
}
// отрисовка сектора
function drawPieSlice(ctx, centerX, centerY, radius, startAngle, endAngle, color) {
ctx.fillStyle = color;
ctx.globalAlpha = 0.8;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.arc(centerX, centerY, radius, startAngle, endAngle);
ctx.closePath();
ctx.fill();
}
// проставка временной отметки
function drawTimestamp(ctx) {
let now = new Date();
let day, month, hour, minute, second;
if (now.getDate() < 10) {
day = "0" + now.getDate();
} else {
day = now.getDate();
}
if (now.getMonth() < 10) {
month = "0" + (now.getMonth() + 1);
} else {
month = (now.getMonth() + 1);
}
if (now.getHours() < 10) {
hour = "0" + now.getHours();
} else {
hour = now.getHours();
}
if (now.getMinutes() < 10) {
minute = "0" + now.getMinutes();
} else {
minute = now.getMinutes();
}
if (now.getSeconds() < 10) {
second = "0" + now.getSeconds();
} else {
second = now.getSeconds();
}
let timestamp = day + "-" + month + "-" + now.getFullYear() + " " + hour + ":" + minute + ":" + second;
ctx.fillStyle = "darkgray";
ctx.globalAlpha = 1;
ctx.font = "10pt Roboto";
ctx.fillText(timestamp, myCanvas.width - 127, 15);
}
// отрисовка текста
function drawText(ctx) {
ctx.fillStyle = "#222";
name = " " + param[6].name + " " + param[7].name + " " + param[0].name + " " + param[1].name + " ";
drawTextAlongArc(ctx, name, 240, 240, 208, Math.PI);
name = " " + param[5].name + " " + param[4].name + " " + param[3].name + " " + param[2].name + " ";
drawTextAlongArc(ctx, name, 240, 240, -220, Math.PI * -1);
ctx.fillText(param[0].value, 283, 126);
ctx.fillText(param[1].value, 354, 196);
ctx.fillText(param[2].value, 354, 296);
ctx.fillText(param[3].value, 283, 366);
ctx.fillText(param[4].value, 190, 366);
ctx.fillText(param[5].value, 115, 296);
ctx.fillText(param[6].value, 115, 196);
ctx.fillText(param[7].value, 190, 126);
ctx.fillText(param[8].name + " - " + param[8].value, 45, 490);
ctx.fillText(param[9].name + " - " + (10 - param[9].value), 45, 550);
ctx.fillText("ТРЕВОЖНОСТЬ - " + param[9].value, 315, 550);
}
// отрисовка всего колеса
function drawWheel() {
ctx.clearRect(0, 0, myCanvas.width, myCanvas.height);
ctx.strokeStyle = "#999999";
ctx.fillStyle = "white";
ctx.rect(0, 0, myCanvas.width, myCanvas.height);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "black";
ctx.globalAlpha = 1;
ctx.font = "11pt Roboto";
// отрисовка опорных кругов колеса
for (let i = 1; i <= 10; i++)
drawArc(ctx, 240, 240, i * 20, 0, 360);
// отрисовка опорных линий колеса
drawLine(ctx, 240, 40, 240, 440);
drawLine(ctx, 382, 98, 98, 382);
drawLine(ctx, 98, 98, 382, 382);
drawLine(ctx, 40, 240, 440, 240);
// отрисовка секторов колеса
for (let i = -0.5; i < 1.5; i = i + 0.25) {
value = param[(i + 0.5) / 0.25].value;
name = param[(i + 0.5) / 0.25].name;
if (value <= 4)
color = "#ea4335"; // красный
else if (value >= 8)
color = "#34a853"; // зеленый
else
color = "#fbbc05"; // желтый
drawPieSlice(ctx, 240, 240, 20 * value, Math.PI * i, Math.PI * (i + 0.25), color);
}
ctx.strokeStyle = "#999999";
for (let i = 0; i < 10; i = i + 1) {
ctx.rect(45 + i * 40, 500, 30, 20);
ctx.rect(45 + i * 40, 560, 30, 20);
ctx.stroke();
}
for (let i = 0; i < 10; i = i + 1) {
if (param[8].value < i + 1)
color = "white";
else
if (param[8].value <= 4)
color = "#ea4335" // красный
else if (param[8].value >= 8)
color = "#34a853"; // зеленый
else
color = "#fbbc05"; // желтый
ctx.fillStyle = color;
ctx.fillRect(45 + i * 40, 500, 30, 20);
////////////////////////////////////////////////////
if ((10 - param[9].value) < i + 1)
color = "white";
else
if ((10 - param[9].value) <= 4)
color = "#ea4335" // красный
else if ((10 - param[9].value) >= 8)
color = "#34a853"; // зеленый
else
color = "#fbbc05"; // желтый
ctx.fillStyle = color;
ctx.fillRect(45 + i * 40, 560, 30, 20);
}
drawTimestamp(ctx);
drawText(ctx);
}
// создание подписей вокруг внешнего круга
function drawTextAlongArc(context, str, centerX, centerY, radius, angle) {
let len = str.length,
s;
context.save();
context.translate(centerX, centerY);
context.rotate(-1 * angle / 2);
context.rotate(-1 * (angle / len) / 2);
for (let n = 0; n < len; n++) {
context.rotate(angle / len);
context.save();
context.translate(0, -1 * radius);
s = str[n];
context.fillText(s, 0, 0);
context.restore();
}
context.restore();
}
// действия при сдвиге ползунка
function paramChange(block, id) {
param[id].value = parseInt(block.value) || 0;
document.getElementById("b" + id).value = block.value;
drawWheel();
}
// действия при изменении значения дублирующего input
function param2Change(block, id) {
param[id].value = block.value;
document.getElementById("a" + id).value = block.value;
drawWheel();
}
</script>
{{/if}}

59
views/userspace/votes.hbs Normal file
View file

@ -0,0 +1,59 @@
<div>
<div>
<header class="navbar navbar-expand-md navbar-dark bd-navbar">
<ul class="nav nav-pills container flex-wrap flex-md-nowrap gap-2">
<li class="nav-item">
<a class="nav-link" href="/userspace">Вернуться</a>
</li>
<div class="flex-grow-1"></div>
<li class="nav-item">
<a class="nav-link active" href="/gateway/logout">Выйти</a>
</li>
</ul>
</header>
<div class="container p-0">
<div>
<table id="votesTable" class="w-100 table table-sm table-bordered">
<thead>
<tr>
<th scope="col">Когда</th>
<th scope="col">Здоровье</th>
<th scope="col">Любовь</th>
<th scope="col">Секс</th>
<th scope="col">Работа</th>
<th scope="col">Отдых</th>
<th scope="col">Деньги</th>
<th scope="col">Отношения</th>
<th scope="col">Личн. рост</th>
<th scope="col">Смысл жизни</th>
<th scope="col">Тревожность</th>
</tr>
</thead>
<tbody class="table-group-divider">
{{#each votes}}
<tr>
<td scope="row">{{date}}</td>
<td scope="row">{{health}}</td>
<td scope="row">{{love}}</td>
<td scope="row">{{sex}}</td>
<td scope="row">{{work}}</td>
<td scope="row">{{rest}}</td>
<td scope="row">{{finances}}</td>
<td scope="row">{{relations}}</td>
<td scope="row">{{pers_growth}}</td>
<td scope="row">{{meaning_of_life}}</td>
<td scope="row">{{serenity}}</td>
</tr>
{{else}}
<tr>
<td colspan="11" class="text-center">Нет данных</td>
</tr>
{{/each}}
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>