novela/containers/novela/templates/stats.html
2026-03-31 20:03:18 +02:00

316 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Statistics</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
/* ── Main ── */
.main { margin-left: var(--sidebar); min-height: 100vh; padding: 2rem 2.5rem 4rem; }
@media (max-width: 768px) {
.main { margin-left: 0; padding: 4rem 1rem 4rem; }
}
.main-title {
font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--accent); margin-bottom: 1.75rem;
}
/* ── Stat cards ── */
.stat-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 2rem; }
.stat-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 1.25rem 1.5rem;
}
.stat-card-label {
font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--text-dim); margin-bottom: 0.4rem;
}
.stat-card-value { font-size: 1.75rem; font-weight: 700; color: var(--accent); line-height: 1; }
.stat-card-value.text-val { font-size: 1.05rem; }
.stat-card-sub {
font-family: var(--mono); font-size: 0.7rem; color: var(--text-dim);
margin-top: 0.35rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ── Chart cards ── */
.charts-row { display: grid; gap: 1.5rem; margin-bottom: 1.5rem; }
.charts-row-1 { grid-template-columns: 1fr; }
.charts-row-2 { grid-template-columns: 1fr 1fr; }
.charts-row-3 { grid-template-columns: 1fr 1fr 1fr; }
.chart-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 1.5rem;
}
.chart-card-title {
font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--text-dim); margin-bottom: 1.1rem;
}
.chart-wrap { position: relative; height: 220px; }
.chart-wrap-tall { position: relative; height: 280px; }
/* ── History ── */
.history-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 1.5rem; margin-top: 1.5rem;
}
.history-title {
font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--text-dim); margin-bottom: 1rem;
}
.history-table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 0.75rem; }
.history-table th {
color: var(--accent); text-align: left; padding: 0.35rem 0.75rem;
border-bottom: 1px solid var(--border); letter-spacing: 0.05em;
}
.history-table td { padding: 0.45rem 0.75rem; border-bottom: 1px solid var(--border); color: var(--text-dim); }
.history-table tr:last-child td { border-bottom: none; }
.history-table tr:hover td { background: var(--surface2); }
.history-table td.title-col { color: var(--text); }
.tag-pill {
display: inline-block; font-size: 0.6rem; padding: 0.1rem 0.4rem;
border-radius: 3px; background: var(--surface2); color: var(--text-dim);
border: 1px solid var(--border); margin: 0.1rem 0.1rem 0 0;
}
.empty-msg {
font-family: var(--mono); font-size: 0.82rem; color: var(--text-dim);
text-align: center; padding: 3rem 2rem;
}
</style>
</head>
<body>
{% include "_sidebar.html" %}
<main class="main">
<div class="main-title">Reading Statistics</div>
<div class="stat-cards">
<div class="stat-card">
<div class="stat-card-label">Total reads</div>
<div class="stat-card-value" id="s-total"></div>
</div>
<div class="stat-card">
<div class="stat-card-label">Books read</div>
<div class="stat-card-value" id="s-unique"></div>
<div class="stat-card-sub">unique titles</div>
</div>
<div class="stat-card">
<div class="stat-card-label">Favourite genre</div>
<div class="stat-card-value text-val" id="s-genre"></div>
<div class="stat-card-sub" id="s-genre-sub"></div>
</div>
<div class="stat-card">
<div class="stat-card-label">Publisher</div>
<div class="stat-card-value text-val" id="s-pub"></div>
<div class="stat-card-sub" id="s-pub-sub"></div>
</div>
</div>
<!-- Reads per month -->
<div class="charts-row charts-row-1">
<div class="chart-card">
<div class="chart-card-title">Reads per month — last 12 months</div>
<div class="chart-wrap"><canvas id="chart-month"></canvas></div>
</div>
</div>
<!-- Day + hour -->
<div class="charts-row charts-row-2">
<div class="chart-card">
<div class="chart-card-title">Day of the week</div>
<div class="chart-wrap"><canvas id="chart-dow"></canvas></div>
</div>
<div class="chart-card">
<div class="chart-card-title">Hour of the day</div>
<div class="chart-wrap"><canvas id="chart-hour"></canvas></div>
</div>
</div>
<!-- Genre + top books -->
<div class="charts-row charts-row-2">
<div class="chart-card">
<div class="chart-card-title">Genre distribution (library)</div>
<div class="chart-wrap-tall"><canvas id="chart-genre"></canvas></div>
</div>
<div class="chart-card">
<div class="chart-card-title">Most read books</div>
<div class="chart-wrap-tall"><canvas id="chart-top"></canvas></div>
</div>
</div>
<!-- History -->
<div class="history-card">
<div class="history-title">Reading history — last 50 sessions</div>
<div id="history-container">
<div class="empty-msg">Loading…</div>
</div>
</div>
</main>
<script src="/static/books.js"></script>
<script>
Chart.defaults.color = '#8a8278';
Chart.defaults.borderColor = '#2e2a24';
Chart.defaults.font.family = "'DM Mono', monospace";
Chart.defaults.font.size = 11;
const ACCENT = '#ffa20e';
const ACCENT_A = 'rgba(255,162,14,0.15)';
function fmtDate(iso) {
const d = new Date(iso);
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
+ ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
}
const CHART_OPTIONS_BAR = (indexAxis = 'x') => ({
indexAxis,
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: indexAxis === 'y' ? '#2e2a24' : 'transparent' }, beginAtZero: true, ticks: { precision: 0 } },
y: { grid: { color: indexAxis === 'x' ? '#2e2a24' : 'transparent' }, ticks: { font: { size: 10 } } },
},
});
async function loadStats() {
const resp = await fetch('/api/stats');
const d = await resp.json();
// Summary
document.getElementById('s-total').textContent = d.total_reads;
document.getElementById('s-unique').textContent = d.unique_books_read;
if (d.fav_genre) {
document.getElementById('s-genre').textContent = d.fav_genre;
const gc = d.genre_counts.find(g => g.name === d.fav_genre);
if (gc) document.getElementById('s-genre-sub').textContent = gc.count + ' books';
}
if (d.fav_publisher) {
document.getElementById('s-pub').textContent = d.fav_publisher;
const pc = d.publisher_counts.find(p => p.name === d.fav_publisher);
if (pc) document.getElementById('s-pub-sub').textContent = pc.count + ' books';
}
// Reads per month
new Chart(document.getElementById('chart-month'), {
type: 'line',
data: {
labels: d.reads_by_month.map(r => r.month),
datasets: [{
data: d.reads_by_month.map(r => r.count),
borderColor: ACCENT, backgroundColor: ACCENT_A,
fill: true, tension: 0.35,
pointBackgroundColor: ACCENT, pointRadius: 4, pointHoverRadius: 6,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: '#2e2a24' } },
y: { grid: { color: '#2e2a24' }, beginAtZero: true, ticks: { precision: 0 } },
},
},
});
// Day of week
const DOW = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
new Chart(document.getElementById('chart-dow'), {
type: 'bar',
data: {
labels: DOW,
datasets: [{ data: d.reads_by_dow, backgroundColor: ACCENT, borderRadius: 3 }],
},
options: CHART_OPTIONS_BAR('x'),
});
// Hour of day
const HOURS = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2,'0')}:00`);
new Chart(document.getElementById('chart-hour'), {
type: 'bar',
data: {
labels: HOURS,
datasets: [{ data: d.reads_by_hour, backgroundColor: ACCENT, borderRadius: 2 }],
},
options: {
...CHART_OPTIONS_BAR('x'),
scales: {
x: { grid: { display: false }, ticks: { maxRotation: 45, autoSkip: true, maxTicksLimit: 12 } },
y: { grid: { color: '#2e2a24' }, beginAtZero: true, ticks: { precision: 0 } },
},
},
});
// Genre distribution (top 12)
const genres = d.genre_counts.slice(0, 12);
new Chart(document.getElementById('chart-genre'), {
type: 'bar',
data: {
labels: genres.map(g => g.name),
datasets: [{ data: genres.map(g => g.count), backgroundColor: ACCENT, borderRadius: 3 }],
},
options: CHART_OPTIONS_BAR('y'),
});
// Top books
if (d.top_books.length) {
new Chart(document.getElementById('chart-top'), {
type: 'bar',
data: {
labels: d.top_books.map(b => b.title.length > 24 ? b.title.slice(0, 23) + '…' : b.title),
datasets: [{ data: d.top_books.map(b => b.count), backgroundColor: ACCENT, borderRadius: 3 }],
},
options: CHART_OPTIONS_BAR('y'),
});
} else {
document.getElementById('chart-top').parentElement.innerHTML =
'<div class="empty-msg" style="height:100%;display:flex;align-items:center;justify-content:center">No reads recorded yet.</div>';
}
// History table
const container = document.getElementById('history-container');
if (!d.history.length) {
container.innerHTML = '<div class="empty-msg">No reading history yet. Use the book icon on a library card to mark a book as read.</div>';
return;
}
container.innerHTML = `
<table class="history-table">
<thead>
<tr>
<th>Title</th><th>Author</th><th>Genres</th><th>Publisher</th><th>Read at</th>
</tr>
</thead>
<tbody>
${d.history.map(h => `
<tr>
<td class="title-col">${esc(h.title)}</td>
<td>${esc(h.author)}</td>
<td>${(h.genres || []).slice(0,4).map(g => `<span class="tag-pill">${esc(g)}</span>`).join('')}</td>
<td>${esc(h.publisher)}</td>
<td style="white-space:nowrap;color:var(--text-dim)">${fmtDate(h.read_at)}</td>
</tr>`).join('')}
</tbody>
</table>`;
}
loadStats();
</script>
</body>
</html>