Files
Super-HomeworkManager/web/templates/assignments/statistics.html
2026-01-08 23:58:15 +09:00

561 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{template "base" .}}
{{define "head"}}
<style>
.stat-card {
transition: transform 0.2s ease-in-out;
}
.stat-card:hover {
transform: translateY(-5px);
}
.progress {
height: 25px;
}
.progress-bar {
font-size: 0.9rem;
font-weight: 500;
}
.subject-row:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.table-progress {
height: 20px;
}
.pagination-info {
font-size: 0.875rem;
}
.stats-table {
min-width: 700px;
}
.stats-table th,
.stats-table td {
white-space: nowrap;
}
.stats-table th:first-child,
.stats-table td:first-child {
min-width: 120px;
}
#shareCard {
width: 600px;
height: 315px;
position: fixed;
left: -9999px;
top: 0;
background: linear-gradient(135deg, #005bea 0%, #00c6fb 100%);
padding: 2rem;
color: white;
border-radius: 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
#shareCard .card-title {
font-size: 1.5rem;
font-weight: bold;
opacity: 0.9;
}
#shareCard .rate-display {
font-size: 5rem;
font-weight: bold;
line-height: 1;
margin: 1rem 0;
}
#shareCard .stats-row {
display: flex;
justify-content: space-around;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 1rem;
backdrop-filter: blur(5px);
}
#shareCard .stat-item {
text-align: center;
}
#shareCard .stat-label {
font-size: 0.8rem;
opacity: 0.8;
display: block;
margin-bottom: 0.2rem;
}
#shareCard .stat-value {
font-size: 1.4rem;
font-weight: bold;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-bar-chart me-2"></i>統計</h1>
<a href="/assignments" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>課題一覧に戻る
</a>
</div>
<div class="card mb-4">
<div class="card-body">
<form method="GET" action="/statistics" class="row g-3">
<div class="col-md-4">
<label class="form-label">科目</label>
<select name="subject" class="form-select">
<option value="">すべての科目</option>
{{range .subjects}}
<option value="{{.}}" {{if eq . $.selectedSubject}}selected{{end}}>{{.}}{{if index
$.archivedSubjects .}} (アーカイブ済){{end}}</option>
{{end}}
</select>
</div>
<div class="col-md-2">
<label class="form-label">登録日(開始)</label>
<input type="date" name="from" class="form-control" value="{{.fromDate}}">
</div>
<div class="col-md-2">
<label class="form-label">登録日(終了)</label>
<input type="date" name="to" class="form-control" value="{{.toDate}}">
</div>
<div class="col-md-4 d-flex align-items-end">
<button type="submit" class="btn btn-primary me-2">
<i class="bi bi-filter me-1"></i>絞り込み
</button>
<a href="/statistics" class="btn btn-outline-secondary">
<i class="bi bi-x-lg me-1"></i>リセット
</a>
</div>
</form>
</div>
</div>
<div class="row g-4 mb-4">
<div class="col-6 col-lg-3">
<div class="card stat-card bg-primary text-white h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-white-50 mb-1">総課題数</h6>
<h2 class="mb-0">{{.stats.TotalAssignments}}</h2>
</div>
<i class="bi bi-list-task display-4 opacity-50"></i>
</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="card stat-card bg-success text-white h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-white-50 mb-1">完了</h6>
<h2 class="mb-0">{{.stats.CompletedAssignments}}</h2>
</div>
<i class="bi bi-check-circle display-4 opacity-50"></i>
</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="card stat-card bg-warning text-dark h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-dark-50 mb-1">未完了</h6>
<h2 class="mb-0">{{.stats.PendingAssignments}}</h2>
</div>
<i class="bi bi-hourglass-split display-4 opacity-50"></i>
</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="card stat-card bg-danger text-white h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-white-50 mb-1">期限切れ</h6>
<h2 class="mb-0">{{.stats.OverdueAssignments}}</h2>
</div>
<i class="bi bi-exclamation-triangle display-4 opacity-50"></i>
</div>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-clock-history me-2"></i>期限内完了率</span>
<button class="btn btn-sm btn-outline-primary" onclick="generateShareImage()">
<i class="bi bi-share me-1"></i>シェア
</button>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-3 text-center mb-3 mb-md-0">
<h1
class="display-3 mb-0 {{if ge .stats.OnTimeCompletionRate 80.0}}text-success{{else if ge .stats.OnTimeCompletionRate 50.0}}text-warning{{else}}text-danger{{end}}">
{{printf "%.1f" .stats.OnTimeCompletionRate}}%
</h1>
<small class="text-muted">期限内完了率</small>
</div>
<div class="col-md-9">
<div class="progress">
{{if ge .stats.OnTimeCompletionRate 80.0}}
<div class="progress-bar bg-success" role="progressbar"
style="width: {{.stats.OnTimeCompletionRate}}%">
{{printf "%.1f" .stats.OnTimeCompletionRate}}% 期限内に完了
</div>
{{else if ge .stats.OnTimeCompletionRate 50.0}}
<div class="progress-bar bg-warning" role="progressbar"
style="width: {{.stats.OnTimeCompletionRate}}%">
{{printf "%.1f" .stats.OnTimeCompletionRate}}% 期限内に完了
</div>
{{else}}
<div class="progress-bar bg-danger" role="progressbar"
style="width: {{.stats.OnTimeCompletionRate}}%">
{{printf "%.1f" .stats.OnTimeCompletionRate}}% 期限内に完了
</div>
{{end}}
</div>
<small class="text-muted mt-2 d-block">完了した課題のうち、期限内に完了した割合を表示しています。</small>
</div>
</div>
</div>
</div>
<div class="card mb-4" id="activeSubjectsCard">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-collection me-2"></i>アクティブ科目</span>
<span class="badge bg-primary" id="activeCount">0</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 stats-table">
<thead class="table-light">
<tr>
<th>科目</th>
<th class="text-center">総数</th>
<th class="text-center">完了</th>
<th class="text-center">未完了</th>
<th class="text-center">期限切れ</th>
<th class="text-center">完了率</th>
<th style="width: 150px;">進捗</th>
<th class="text-center">操作</th>
</tr>
</thead>
<tbody id="activeSubjectsBody"></tbody>
</table>
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<span class="pagination-info" id="activePageInfo"></span>
<nav>
<ul class="pagination pagination-sm mb-0" id="activePagination"></ul>
</nav>
</div>
</div>
<div class="card" id="archivedSubjectsCard">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-archive me-2"></i>アーカイブ済み科目</span>
<span class="badge bg-secondary" id="archivedCount">0</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 stats-table">
<thead class="table-light">
<tr>
<th>科目</th>
<th class="text-center">総数</th>
<th class="text-center">完了</th>
<th class="text-center">未完了</th>
<th class="text-center">期限切れ</th>
<th class="text-center">完了率</th>
<th style="width: 150px;">進捗</th>
<th class="text-center">操作</th>
</tr>
</thead>
<tbody id="archivedSubjectsBody"></tbody>
</table>
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<span class="pagination-info" id="archivedPageInfo"></span>
<nav>
<ul class="pagination pagination-sm mb-0" id="archivedPagination"></ul>
</nav>
</div>
</div>
<div class="card d-none" id="noSubjectsCard">
<div class="card-body text-center py-5">
<i class="bi bi-inbox display-1 text-muted"></i>
<h4 class="mt-3">科目別の統計データがありません</h4>
<p class="text-muted">課題を登録して科目を設定すると、ここに統計が表示されます。</p>
</div>
</div>
<div id="shareCard">
<div>
<div class="d-flex align-items-center mb-4">
<i class="bi bi-journal-check me-2" style="font-size: 1.5rem;"></i>
<span class="card-title">Super Homework Manager</span>
</div>
</div>
<div class="text-center">
<div style="font-size: 1.5rem; font-weight: bold; margin-bottom: 0.5rem; opacity: 0.9;">期限内完了率</div>
<div class="rate-display" style="margin-top: 0;">
{{printf "%.1f" .stats.OnTimeCompletionRate}}<span style="font-size: 2.5rem;">%</span>
</div>
</div>
<div class="stats-row">
<div class="stat-item">
<span class="stat-label">完了</span>
<span class="stat-value">{{.stats.CompletedAssignments}}</span>
</div>
<div class="stat-item">
<span class="stat-label">未完了</span>
<span class="stat-value">{{.stats.PendingAssignments}}</span>
</div>
<div class="stat-item">
<span class="stat-label">期限切れ</span>
<span class="stat-value">{{.stats.OverdueAssignments}}</span>
</div>
</div>
</div>
<!-- Share Modal -->
<div class="modal fade" id="shareModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-share me-2"></i>シェア</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<div id="sharePreviewContainer" class="mb-3" style="max-width: 100%; overflow: hidden;">
</div>
<p class="text-muted small mb-3">
画像を保存またはコピーして、SNSに貼り付けてください。<br>
<span class="text-danger"><i class="bi bi-info-circle me-1"></i>ブラウザの制限により、自動で画像は添付されません。</span>
</p>
<div class="d-grid gap-2">
<button class="btn btn-outline-primary" onclick="copyImageToClipboard(this)">
<i class="bi bi-clipboard me-2"></i>画像をコピー
</button>
<a id="downloadLink" class="btn btn-outline-secondary" download="stats.png">
<i class="bi bi-download me-2"></i>画像を保存
</a>
<a id="twitterShareBtn" href="#" target="_blank" class="btn btn-dark"
style="background-color: #000;">
<i class="bi bi-twitter-x me-2"></i>Xでポストする
</a>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script id="subjectsData" type="application/json">
{"csrfToken":"{{.csrfToken}}","subjects":[{{range $i, $s := .stats.Subjects}}{{if $i}},{{end}}{"subject":"{{$s.Subject}}","total":{{$s.Total}},"completed":{{$s.Completed}},"pending":{{$s.Pending}},"overdue":{{$s.Overdue}},"rate":{{$s.OnTimeCompletionRate}},"isArchived":{{if index $.archivedSubjects $s.Subject}}true{{else}}false{{end}}}{{end}}]}
</script>
<script>
(function () {
var data = JSON.parse(document.getElementById('subjectsData').textContent);
var csrfToken = data.csrfToken;
var subjects = data.subjects;
var PAGE_SIZE = 10;
var activeSubjects = subjects.filter(function (s) { return !s.isArchived; });
var archivedSubjects = subjects.filter(function (s) { return s.isArchived; });
var activePage = 1;
var archivedPage = 1;
// Share Functionality
window.generateShareImage = function () {
var card = document.getElementById('shareCard');
// Ensure card is visible for rendering but off-screen
card.style.display = 'flex';
html2canvas(card, {
backgroundColor: null,
scale: 2 // High resolution
}).then(canvas => {
var imgData = canvas.toDataURL('image/png');
// Set up preview
var previewContainer = document.getElementById('sharePreviewContainer');
previewContainer.innerHTML = '';
var img = document.createElement('img');
img.src = imgData;
img.style.maxWidth = '100%';
img.style.borderRadius = '8px';
img.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
previewContainer.appendChild(img);
// Set up download link
var downloadLink = document.getElementById('downloadLink');
downloadLink.href = imgData;
// Set up Twitter button
var text = '私の課題完了状況\n期限内完了率: {{printf "%.1f" .stats.OnTimeCompletionRate}}%\n#SuperHomeworkManager';
var twitterBtn = document.getElementById('twitterShareBtn');
twitterBtn.href = "https://twitter.com/intent/tweet?text=" + encodeURIComponent(text);
// Show modal
var modal = new bootstrap.Modal(document.getElementById('shareModal'));
modal.show();
});
};
// Helper to convert Data URL to Blob
function dataURLtoBlob(dataurl) {
var arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
}
window.copyImageToClipboard = function (btn) {
var canvas = document.querySelector('#sharePreviewContainer img');
if (!canvas) return;
if (!navigator.clipboard) {
alert('このブラウザまたは環境非HTTPS/非localhostでは、クリップボードへのコピー機能がサポートされていません。\n「画像を保存」を使用してください。');
return;
}
try {
var blob = dataURLtoBlob(canvas.src);
navigator.clipboard.write([
new ClipboardItem({
'image/png': blob
})
]).then(function () {
var originalText = btn.innerHTML;
btn.innerHTML = '<i class="bi bi-check me-2"></i>コピーしました';
btn.classList.remove('btn-outline-primary');
btn.classList.add('btn-success');
setTimeout(function () {
btn.innerHTML = originalText;
btn.classList.add('btn-outline-primary');
btn.classList.remove('btn-success');
}, 2000);
}).catch(function (err) {
console.error('Failed to copy: ', err);
alert('画像のコピーに失敗しました。\nエラー: ' + err.message + '\n「画像を保存」を使用してください。');
});
} catch (err) {
console.error('Failed to create blob: ', err);
alert('画像データの生成に失敗しました: ' + err.message);
}
};
function getRateClass(rate) {
if (rate >= 80) return 'text-success';
if (rate >= 50) return 'text-warning';
return 'text-danger';
}
function renderProgress(completed, pending, overdue, total) {
if (total === 0) return '<div class="progress table-progress"></div>';
var cP = (completed / total * 100).toFixed(1);
var pP = (pending / total * 100).toFixed(1);
var oP = (overdue / total * 100).toFixed(1);
return '<div class="progress table-progress">' +
'<div class="progress-bar bg-success" style="width:' + cP + '%" title="完了: ' + completed + '"></div>' +
'<div class="progress-bar bg-warning" style="width:' + pP + '%" title="未完了: ' + pending + '"></div>' +
'<div class="progress-bar bg-danger" style="width:' + oP + '%" title="期限切れ: ' + overdue + '"></div></div>';
}
function renderRow(s, isArchived) {
var action = isArchived ?
'<form action="/statistics/unarchive-subject" method="POST" class="d-inline"><input type="hidden" name="_csrf" value="' + csrfToken + '"><input type="hidden" name="subject" value="' + s.subject + '"><button type="submit" class="btn btn-sm btn-outline-success" title="復元"><i class="bi bi-arrow-counterclockwise"></i></button></form>' :
'<form action="/statistics/archive-subject" method="POST" class="d-inline"><input type="hidden" name="_csrf" value="' + csrfToken + '"><input type="hidden" name="subject" value="' + s.subject + '"><button type="submit" class="btn btn-sm btn-outline-secondary" title="アーカイブ"><i class="bi bi-archive"></i></button></form>';
return '<tr class="subject-row">' +
'<td><a href="/statistics?subject=' + encodeURIComponent(s.subject) + '" class="text-decoration-none"><i class="bi bi-folder me-1"></i>' + s.subject + '</a></td>' +
'<td class="text-center">' + s.total + '</td>' +
'<td class="text-center text-success">' + s.completed + '</td>' +
'<td class="text-center text-warning">' + s.pending + '</td>' +
'<td class="text-center text-danger">' + s.overdue + '</td>' +
'<td class="text-center"><span class="' + getRateClass(s.rate) + '">' + s.rate.toFixed(1) + '%</span></td>' +
'<td>' + renderProgress(s.completed, s.pending, s.overdue, s.total) + '</td>' +
'<td class="text-center">' + action + '</td></tr>';
}
function renderPagination(id, page, total, cb) {
var el = document.getElementById(id);
el.innerHTML = '';
if (total <= 1) return;
var prev = document.createElement('li');
prev.className = 'page-item' + (page === 1 ? ' disabled' : '');
prev.innerHTML = '<a class="page-link" href="#">&laquo;</a>';
if (page > 1) prev.onclick = function (e) { e.preventDefault(); cb(page - 1); };
el.appendChild(prev);
for (var i = 1; i <= total; i++) {
var li = document.createElement('li');
li.className = 'page-item' + (i === page ? ' active' : '');
li.innerHTML = '<a class="page-link" href="#">' + i + '</a>';
(function (p) { li.onclick = function (e) { e.preventDefault(); cb(p); }; })(i);
el.appendChild(li);
}
var next = document.createElement('li');
next.className = 'page-item' + (page === total ? ' disabled' : '');
next.innerHTML = '<a class="page-link" href="#">&raquo;</a>';
if (page < total) next.onclick = function (e) { e.preventDefault(); cb(page + 1); };
el.appendChild(next);
}
function renderActiveSubjects() {
var start = (activePage - 1) * PAGE_SIZE;
var end = Math.min(start + PAGE_SIZE, activeSubjects.length);
var totalPages = Math.ceil(activeSubjects.length / PAGE_SIZE);
document.getElementById('activeSubjectsBody').innerHTML = activeSubjects.slice(start, end).map(function (s) { return renderRow(s, false); }).join('');
document.getElementById('activeCount').textContent = activeSubjects.length;
document.getElementById('activePageInfo').textContent = activeSubjects.length > 0 ? (start + 1) + '-' + end + ' / ' + activeSubjects.length + ' 件' : '0 件';
renderPagination('activePagination', activePage, totalPages, function (p) { activePage = p; renderActiveSubjects(); });
document.getElementById('activeSubjectsCard').style.display = activeSubjects.length > 0 ? 'block' : 'none';
}
function renderArchivedSubjects() {
var start = (archivedPage - 1) * PAGE_SIZE;
var end = Math.min(start + PAGE_SIZE, archivedSubjects.length);
var totalPages = Math.ceil(archivedSubjects.length / PAGE_SIZE);
document.getElementById('archivedSubjectsBody').innerHTML = archivedSubjects.slice(start, end).map(function (s) { return renderRow(s, true); }).join('');
document.getElementById('archivedCount').textContent = archivedSubjects.length;
document.getElementById('archivedPageInfo').textContent = archivedSubjects.length > 0 ? (start + 1) + '-' + end + ' / ' + archivedSubjects.length + ' 件' : '0 件';
renderPagination('archivedPagination', archivedPage, totalPages, function (p) { archivedPage = p; renderArchivedSubjects(); });
document.getElementById('archivedSubjectsCard').style.display = archivedSubjects.length > 0 ? 'block' : 'none';
}
renderActiveSubjects();
renderArchivedSubjects();
if (activeSubjects.length === 0 && archivedSubjects.length === 0) {
document.getElementById('noSubjectsCard').classList.remove('d-none');
document.getElementById('activeSubjectsCard').style.display = 'none';
document.getElementById('archivedSubjectsCard').style.display = 'none';
}
})();
</script>
{{end}}