316 lines
12 KiB
HTML
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>
|