reading: finished-books widget in the rail + full list at /reading/finished

Sidebar widget (FinishedBooks.astro, next to ReadingNow) shows the 5 most recent
finished books, title-only-linked to their Quotes page when one exists, with a
"see all" link once there are more than 5 — that link is the sole path to the full
page, which carries the fuller kobo-dashboard-style detail (duration, pages,
pages/hr, gap-since-last-finish). Both fetch client-side against the new
api.c0smere.net/reading/finished, same fail-closed pattern as ReadingNow.
This commit is contained in:
wes
2026-08-24 07:44:44 -04:00
parent d3d93a982a
commit 172f5c3999
3 changed files with 303 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
---
/**
* "Finished" — the 5 most recently finished books, with a link to the full list.
*
* import FinishedBooks from '../components/FinishedBooks.astro';
* <FinishedBooks />
*
* Data: api.c0smere.net/reading/finished — the same array /reading/finished.astro renders in
* full; this widget just takes the first 5 client-side rather than adding a ?limit param to an
* endpoint whose only other consumer wants everything.
*
* Deliberately terse, matching ReadingNow at this rail width (13rem): title + date only, no
* duration/pages/author — those live on the full page. Title links to the book's Quotes page
* only when quote_slug is present; see api.reading_finished()'s comment (grants_phlegethon.sql)
* for why a non-null slug is always a real page.
*/
---
<section class="finished-books" id="finished-books" hidden>
<h2 class="finished-books__label">Finished</h2>
<ol class="finished-books__list" id="fb-list"></ol>
<p class="finished-books__more" id="fb-more" hidden>
<a href="/reading/finished">see all &rarr;</a>
</p>
</section>
<script>
// Fail closed, like ReadingNow: the section ships hidden and only a good response reveals
// it, so a 404 (nothing finished yet), a dead API, or an empty array leaves a plain rail.
try {
const res = await fetch('https://api.c0smere.net/reading/finished');
if (!res.ok) throw new Error(String(res.status));
const books = await res.json();
if (!Array.isArray(books) || books.length === 0) throw new Error('empty');
const list = document.getElementById('fb-list')!;
for (const b of books.slice(0, 5)) {
const li = document.createElement('li');
li.className = 'finished-books__item';
const title = document.createElement(b.quote_slug ? 'a' : 'span');
title.className = 'finished-books__title';
title.textContent = b.title;
if (b.quote_slug) title.setAttribute('href', `/Quotes/${b.quote_slug}`);
li.appendChild(title);
const date = document.createElement('span');
date.className = 'finished-books__date';
date.textContent = b.finished_on;
li.appendChild(date);
list.appendChild(li);
}
if (books.length > 5) {
document.getElementById('fb-more')!.hidden = false;
}
document.getElementById('finished-books')!.hidden = false;
} catch {
/* API down, blocked, or nothing finished yet: the slot stays hidden */
}
</script>
<style>
.finished-books {
margin-top: 1.2rem;
padding-top: 1rem;
border-top: 1px solid var(--hair);
font-size: 0.82rem;
line-height: 1.4;
}
.finished-books[hidden] { display: none; }
.finished-books__label {
margin: 0 0 0.5rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
}
.finished-books__list {
list-style: none;
margin: 0;
padding: 0;
}
.finished-books__item { margin: 0 0 0.55rem; }
.finished-books__item:last-child { margin-bottom: 0; }
.finished-books__title {
display: block;
font-weight: 600;
color: var(--fg);
text-decoration: none;
/* Titles run long ("Introduction to Linear Algebra (Ed 5) …") on a 13rem rail. */
overflow-wrap: anywhere;
}
a.finished-books__title:hover { color: var(--link-bright); }
.finished-books__date {
display: block;
color: var(--muted);
font-size: 0.76rem;
}
.finished-books__more { margin: 0.6rem 0 0; }
.finished-books__more[hidden] { display: none; }
.finished-books__more a {
color: var(--muted);
text-decoration: none;
}
.finished-books__more a:hover { color: var(--link-bright); }
</style>
+2
View File
@@ -6,6 +6,7 @@ import { COMMIT, BUILT_AT } from '../lib/build-info.mjs';
import NavTree from '../components/NavTree.astro';
import Toc from '../components/Toc.astro';
import ReadingNow from '../components/ReadingNow.astro';
import FinishedBooks from '../components/FinishedBooks.astro';
import { loadNotebooks, notebookUrl } from '../lib/notebooks.mjs';
interface Props {
@@ -205,6 +206,7 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
<aside class="toc-col">
{showToc && <Toc headings={headings} />}
<ReadingNow />
<FinishedBooks />
</aside>
</div>
+186
View File
@@ -0,0 +1,186 @@
---
import Base from '../../layouts/Base.astro';
---
<Base
title="Finished books"
description="Every book I've finished reading, in order, with what it took and the gaps in between."
>
<h1>Finished books</h1>
<p>
The rail on every page carries the five most recent. This is the whole list: when I
finished each one, how long it took by the device's own cumulative counter, and — where
I took any highlights — a link through to <a href="/Quotes/">the quotes I kept</a>.
</p>
<ol class="finished-list" id="finished-list" hidden></ol>
<p class="finished-empty" id="finished-empty" hidden>Nothing finished yet.</p>
</Base>
<script>
try {
const res = await fetch('https://api.c0smere.net/reading/finished');
if (!res.ok) throw new Error(String(res.status));
const books = await res.json();
if (!Array.isArray(books) || books.length === 0) throw new Error('empty');
const list = document.getElementById('finished-list')!;
books.forEach((book, idx) => {
const li = document.createElement('li');
li.className = 'finished-item';
const when = document.createElement('span');
when.className = 'finished-when';
const dateEl = document.createElement('span');
dateEl.className = 'finished-date';
dateEl.textContent = longDay(book.finished_on);
when.appendChild(dateEl);
// How long after the *previous* finish this one landed. The list runs newest-first,
// so the previous book chronologically is the next row down — and the oldest book has
// nothing before it to measure against. Ported from kobo-dashboard's FinishedShelf.
if (idx < books.length - 1) {
const gapEl = document.createElement('span');
gapEl.className = 'finished-gap';
gapEl.textContent = gapLabel(books[idx + 1].finished_on, book.finished_on);
when.appendChild(gapEl);
}
li.appendChild(when);
const what = document.createElement('span');
what.className = 'finished-what';
const titleEl = document.createElement(book.quote_slug ? 'a' : 'span');
titleEl.className = 'finished-title';
titleEl.textContent = book.title;
if (book.quote_slug) titleEl.setAttribute('href', `/Quotes/${book.quote_slug}`);
what.appendChild(titleEl);
if (book.author) {
const a = document.createElement('span');
a.className = 'finished-author';
a.textContent = book.author;
what.appendChild(a);
}
if (book.series) {
const s = document.createElement('span');
s.className = 'finished-series';
s.textContent = book.series;
what.appendChild(s);
}
li.appendChild(what);
const metrics = document.createElement('span');
metrics.className = 'finished-metrics';
let text = formatDuration(book.minutes);
if (book.pages !== null) {
text += ` · ${book.pages} pages`;
if (book.minutes > 0) {
text += ` · ${Math.round((book.pages / book.minutes) * 60)} pages/hr`;
}
}
metrics.textContent = text;
li.appendChild(metrics);
list.appendChild(li);
});
list.hidden = false;
} catch {
document.getElementById('finished-empty')!.hidden = false;
}
/** How long after `earlierISO` the book finished on `laterISO` was finished, in words. */
function gapLabel(earlierISO: string, laterISO: string): string {
const days = daysBetween(earlierISO, laterISO);
if (days <= 0) return 'same day';
if (days === 1) return 'next day';
if (days < 31) return `${days} days later`;
const months = Math.round(days / 30.44);
return months === 1 ? 'a month later' : `${months} months later`;
}
/** Calendar-day difference between two 'YYYY-MM-DD' keys, DST-proof via UTC. */
function daysBetween(from: string, to: string): number {
const [fy, fm, fd] = from.split('-').map(Number);
const [ty, tm, td] = to.split('-').map(Number);
return (Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / 86_400_000;
}
function longDay(iso: string): string {
const [y, m, d] = iso.split('-').map(Number);
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
});
}
function formatDuration(minutes: number): string {
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest === 0 ? `${hours} hr` : `${hours} hr ${rest} min`;
}
</script>
<style>
.finished-list {
list-style: none;
margin: 1.5rem 0 0;
padding: 0;
}
.finished-list[hidden] { display: none; }
.finished-item {
display: grid;
grid-template-columns: 9rem 1fr;
gap: 0.15rem 1rem;
padding: 0.85rem 0;
border-top: 1px solid var(--hair);
}
.finished-item:first-child { border-top: none; }
.finished-when {
display: flex;
flex-direction: column;
color: var(--muted);
font-size: 0.82rem;
}
.finished-gap {
font-size: 0.76rem;
font-style: italic;
}
.finished-what {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0 0.6rem;
}
.finished-title {
font-weight: 600;
color: var(--fg);
text-decoration: none;
}
a.finished-title:hover { color: var(--link-bright); }
.finished-author,
.finished-series {
color: var(--muted);
font-size: 0.88rem;
}
.finished-metrics {
grid-column: 2;
color: var(--muted);
font-size: 0.82rem;
}
.finished-empty { color: var(--muted); }
.finished-empty[hidden] { display: none; }
@media (max-width: 32rem) {
.finished-item { grid-template-columns: 1fr; }
.finished-metrics { grid-column: 1; }
}
</style>