feat(stats): per-device reading breakdown + device attribution on progress syncs
- Attribution: registered clients may now send their device token in
X-Device-Token on PUT /api/media-items/:id/progress (the user JWT
stays primary). The token must map to a device OWNED BY the caller —
foreign/invalid/absent tokens soft-fail to plain web attribution so a
stats decoration can never break a sync. Attributed pushes capture
device_id (FK-safe), device_metadata.source = the registered device
name ('Pixel 9', 'Kindle Paperwhite'), and last_sync surfaces show
that device. Side effect worth having: app-vs-web pushes used to be
LWW-invisible (both sources read 'web'); they now surface as
legitimate cross-source progress conflicts when they diverge.
- /stats gains a By Device panel alongside By Library (grid 3-wide):
reading minutes + sessions + books per registered device, plus one
Web / unsynced bucket for device-less pushes so the panel always
reconciles with the headline totals. Range-aware, family-lens
scoped; renderBars shared between panels in stats.ts.
- The device identity IS the registered platform: devices rows already
carry the name/type the client registered (app sends Build.MODEL +
'mobile' at /api/devices/register, KOReader/Kobo likewise) — no new
identity plumbing on the server.
Tests: capture attributed to the device (row device_id + source
metadata + last_sync_source), foreign/nonexistent tokens soft-fail,
device buckets reconcile (80 attributed + 75 unsynced = 155 total).
Full suite PASS.
This commit is contained in:
1 parent
be10adc24b
commit
7a604c2fec
8 files changed
+399
-20
No files matched your search
+42
-7
@@ -39,6 +39,16 @@ interface LibraryRow {
|
||||
books: number;
|
||||
}
|
||||
|
||||
interface DeviceRow {
|
||||
device_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
minutes: number;
|
||||
pages: number;
|
||||
sessions: number;
|
||||
books: number;
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
range: string;
|
||||
days: number;
|
||||
@@ -58,6 +68,7 @@ interface Summary {
|
||||
most_active_day_minutes: number;
|
||||
top_books: TopBook[];
|
||||
library_breakdown: LibraryRow[];
|
||||
device_breakdown?: DeviceRow[];
|
||||
}
|
||||
|
||||
interface ChartLike {
|
||||
@@ -170,6 +181,7 @@ function renderSummary(summary: Summary): void {
|
||||
|
||||
void renderDailyCharts(summary.daily);
|
||||
renderLibraries(summary.library_breakdown);
|
||||
renderDevices(summary.device_breakdown || []);
|
||||
renderTopBooks(summary.top_books);
|
||||
}
|
||||
|
||||
@@ -231,23 +243,46 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
|
||||
function renderLibraries(rows: LibraryRow[]): void {
|
||||
const container = document.getElementById("stats-library-breakdown");
|
||||
if (!container) return;
|
||||
renderBars(container, rows.map((r) => ({
|
||||
label: r.name,
|
||||
sub: `${fmtMinutes(r.minutes)} · ${r.sessions} sessions`,
|
||||
minutes: r.minutes,
|
||||
})));
|
||||
}
|
||||
|
||||
function renderDevices(rows: DeviceRow[]): void {
|
||||
const container = document.getElementById("stats-device-breakdown");
|
||||
if (!container) return;
|
||||
renderBars(container, rows.map((r) => ({
|
||||
label: r.name || "Web / unsynced",
|
||||
sub: `${r.books} ${r.books === 1 ? "book" : "books"} · ${r.sessions} sessions`,
|
||||
minutes: r.minutes,
|
||||
})));
|
||||
}
|
||||
|
||||
function fmtMinutes(minutes: number): string {
|
||||
return minutes >= 60
|
||||
? `${Math.floor(minutes / 60)}h${minutes % 60 ? ` ${(minutes % 60)}m` : ""}`
|
||||
: `${minutes} min`;
|
||||
}
|
||||
|
||||
function renderBars(
|
||||
container: HTMLElement,
|
||||
rows: Array<{ label: string; sub: string; minutes: number }>,
|
||||
): void {
|
||||
if (rows.length === 0) {
|
||||
container.innerHTML = `<p class="text-sm text-center py-6" style="color: var(--text-secondary)">No library activity in this range.</p>`;
|
||||
container.innerHTML = `<p class="text-sm text-center py-6" style="color: var(--text-secondary)">Nothing recorded in this range yet.</p>`;
|
||||
return;
|
||||
}
|
||||
const max = Math.max(...rows.map((r) => r.minutes), 1);
|
||||
container.innerHTML = rows
|
||||
.map((row) => {
|
||||
const pct = Math.round((row.minutes / max) * 100);
|
||||
const time =
|
||||
row.minutes >= 60
|
||||
? `${Math.floor(row.minutes / 60)}h${row.minutes % 60 ? ` ${(row.minutes % 60)}m` : ""}`
|
||||
: `${row.minutes} min`;
|
||||
return `
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="font-medium" style="color: var(--text-primary)">${escapeHtml(row.name)}</span>
|
||||
<span style="color: var(--text-secondary)">${time} · ${row.sessions} sessions</span>
|
||||
<span class="font-medium" style="color: var(--text-primary)">${escapeHtml(row.label)}</span>
|
||||
<span style="color: var(--text-secondary)">${row.sub}</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full overflow-hidden" style="background-color: var(--bg-primary)">
|
||||
<div class="h-full rounded-full" style="width: ${pct}%; background-color: var(--accent)"></div>
|
||||
|
||||
Reference in new issue
Block a user