feat: implement newsletter browser UI and enhance link filtering functionality

This commit is contained in:
Keith Solomon
2026-05-17 16:56:26 -05:00
parent f4ff04f92b
commit 96f347fd1b
5 changed files with 668 additions and 29 deletions
+4 -1
View File
@@ -99,7 +99,10 @@ Start the local read-only web app with:
nlc serve --host 127.0.0.1 --port 3000
```
The first web UI is intentionally functional: dashboard, links, sponsored links, dead links, and runs.
The web UI is intentionally functional and read-only. It includes dashboard totals, a two-pane
newsletter browser, all links, sponsored links, dead links, and run history. The newsletter browser
opens on the most recent issue to keep the default view focused; switch to **All Issues** when you
want the full link history for the selected newsletter.
## Build and Distribution
+100
View File
@@ -16,6 +16,21 @@ export interface CatalogRunPayload extends CatalogPayload {
errors: number;
}
export interface NewsletterSummary {
id: number;
name: string;
latestIssueDate: string;
issueCount: number;
linkCount: number;
sponsorCount: number;
}
export interface NewsletterLinkQuery {
scope?: 'latest' | 'all';
search?: string;
category?: string;
}
export class CatalogDatabase {
private readonly db: import('node:sqlite').DatabaseSync;
@@ -97,6 +112,91 @@ export class CatalogDatabase {
.all();
}
public newsletterSummaries(search = ''): NewsletterSummary[] {
const searchPattern = `%${search}%`;
return this.db
.prepare(
`SELECT
n.id,
n.name,
COALESCE(MAX(i.issue_date), '') AS latestIssueDate,
COUNT(DISTINCT i.id) AS issueCount,
COUNT(DISTINCT o.id) AS linkCount,
COUNT(DISTINCT s.id) AS sponsorCount
FROM newsletters n
LEFT JOIN issues i ON i.newsletter_id = n.id
LEFT JOIN link_occurrences o ON o.issue_id = i.id
LEFT JOIN sponsors s ON s.newsletter = n.name
WHERE n.name LIKE ?
GROUP BY n.id, n.name
ORDER BY n.name`
)
.all(searchPattern) as unknown as NewsletterSummary[];
}
public newsletterById(id: number): NewsletterSummary | undefined {
return this.newsletterSummaries().find((newsletter) => newsletter.id === id);
}
public categoriesForNewsletter(newsletterId: number): string[] {
return this.db
.prepare(
`SELECT DISTINCT o.category
FROM link_occurrences o
JOIN issues i ON i.id = o.issue_id
WHERE i.newsletter_id = ? AND o.category <> ''
ORDER BY o.category`
)
.all(newsletterId)
.map((row: any) => row.category);
}
public newsletterLinks(newsletterId: number, query: NewsletterLinkQuery = {}): any[] {
const scope = query.scope ?? 'latest';
const filters = ['i.newsletter_id = ?'];
const params: Array<string | number> = [newsletterId];
if (scope === 'latest') {
filters.push('i.issue_date = (SELECT MAX(issue_date) FROM issues WHERE newsletter_id = ?)');
params.push(newsletterId);
}
if (query.search) {
filters.push('(o.title LIKE ? OR o.description LIKE ? OR l.url LIKE ?)');
const search = `%${query.search}%`;
params.push(search, search, search);
}
if (query.category) {
filters.push('o.category = ?');
params.push(query.category);
}
return this.db
.prepare(
`SELECT i.issue_date AS issueDate, o.category, o.title, o.description, l.url, o.also_in AS alsoIn
FROM link_occurrences o
JOIN issues i ON i.id = o.issue_id
JOIN links l ON l.id = o.link_id
WHERE ${filters.join(' AND ')}
ORDER BY i.issue_date DESC, o.title`
)
.all(...params);
}
public allLinks(search = ''): any[] {
const searchPattern = `%${search}%`;
return this.db
.prepare(
`SELECT n.name AS newsletter, i.issue_date AS issueDate, o.category, o.title, l.url, o.description
FROM link_occurrences o
JOIN issues i ON i.id = o.issue_id
JOIN newsletters n ON n.id = i.newsletter_id
JOIN links l ON l.id = o.link_id
WHERE o.title LIKE ? OR o.description LIKE ? OR l.url LIKE ? OR n.name LIKE ?
ORDER BY i.issue_date DESC, n.name, o.title`
)
.all(searchPattern, searchPattern, searchPattern, searchPattern);
}
public sponsoredLinks(): any[] {
return this.db
.prepare('SELECT newsletter, sponsor, description FROM sponsors ORDER BY newsletter, sponsor')
+64 -7
View File
@@ -1,6 +1,6 @@
import express from 'express';
import { CatalogDatabase } from '../database/store.js';
import { dashboard, page, table } from './views.js';
import { dashboard, newsletterBrowser, page, table } from './views.js';
export function createWebApp(databasePath: string) {
const app = express();
@@ -9,24 +9,65 @@ export function createWebApp(databasePath: string) {
withDatabase(databasePath, (db) => res.send(dashboard(db.dashboardCounts()))).catch(next);
});
app.get('/links', (_req, res, next) => {
app.get('/links', (req, res, next) => {
const search = stringQuery(req.query.q);
withDatabase(databasePath, (db) =>
res.send(
page(
'Links',
`<h1>Links</h1>${table(db.contentLinks(), [
`<h1>All Links</h1>
<form class="toolbar" method="get" action="/links">
<label>Search links
<input type="search" name="q" value="${escapeHtml(search)}">
</label>
<button class="button" type="submit">Apply</button>
</form>
${table(db.allLinks(search), [
['newsletter', 'Newsletter'],
['issueDate', 'Issue Date'],
['category', 'Category'],
['title', 'Title'],
['url', 'URL'],
['description', 'Description']
])}`
])}`,
'links'
)
)
).catch(next);
});
app.get('/newsletters', (req, res, next) => {
withDatabase(databasePath, (db) => {
const scope = req.query.scope === 'all' ? 'all' : 'latest';
const linkSearch = stringQuery(req.query.q);
const category = stringQuery(req.query.category);
const newsletters = db.newsletterSummaries();
const requestedNewsletterId = Number(req.query.newsletter);
const selected =
newsletters.find((newsletter) => newsletter.id === requestedNewsletterId) ?? newsletters[0];
res.send(
newsletterBrowser({
newsletters,
selected,
links: selected
? db.newsletterLinks(selected.id, {
category,
scope,
search: linkSearch
})
: [],
categories: selected ? db.categoriesForNewsletter(selected.id) : [],
filters: {
category,
linkSearch,
scope
}
})
);
}).catch(next);
});
app.get('/sponsors', (_req, res, next) => {
withDatabase(databasePath, (db) =>
res.send(
@@ -36,7 +77,8 @@ export function createWebApp(databasePath: string) {
['newsletter', 'Newsletter'],
['sponsor', 'Sponsor'],
['description', 'Description']
])}`
])}`,
'sponsors'
)
)
).catch(next);
@@ -52,7 +94,8 @@ export function createWebApp(databasePath: string) {
['status', 'Status'],
['source', 'Source'],
['date', 'Date']
])}`
])}`,
'dead-links'
)
)
).catch(next);
@@ -71,7 +114,8 @@ export function createWebApp(databasePath: string) {
['sponsors', 'Sponsors'],
['dead_links', 'Dead Links'],
['errors', 'Errors']
])}`
])}`,
'runs'
)
)
).catch(next);
@@ -87,6 +131,19 @@ export function createWebApp(databasePath: string) {
return app;
}
function escapeHtml(value: unknown): string {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function stringQuery(value: unknown): string {
return typeof value === 'string' ? value : '';
}
async function withDatabase(
databasePath: string,
callback: (database: CatalogDatabase) => void
+412 -19
View File
@@ -1,3 +1,19 @@
import { NewsletterSummary } from '../database/store.js';
type ActivePage = 'dashboard' | 'newsletters' | 'links' | 'sponsors' | 'dead-links' | 'runs';
interface NewsletterBrowserModel {
newsletters: NewsletterSummary[];
selected?: NewsletterSummary;
links: Record<string, unknown>[];
categories: string[];
filters: {
category: string;
linkSearch: string;
scope: 'latest' | 'all';
};
}
function escapeHtml(value: unknown): string {
return String(value ?? '')
.replaceAll('&', '&amp;')
@@ -7,7 +23,27 @@ function escapeHtml(value: unknown): string {
.replaceAll("'", '&#39;');
}
export function page(title: string, body: string): string {
function escapeAttr(value: unknown): string {
return escapeHtml(value);
}
function pageUrl(path: string, params: Record<string, string | number | undefined>): string {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
query.set(key, String(value));
}
});
const serialized = query.toString();
return serialized ? `${path}?${serialized}` : path;
}
function navLink(href: string, label: string, pageName: ActivePage, active: ActivePage): string {
const current = pageName === active ? ' aria-current="page"' : '';
return `<a href="${href}"${current}>${escapeHtml(label)}</a>`;
}
export function page(title: string, body: string, active: ActivePage = 'dashboard'): string {
return `<!doctype html>
<html lang="en">
<head>
@@ -15,25 +51,247 @@ export function page(title: string, body: string): string {
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>
body { font-family: Arial, sans-serif; margin: 2rem; color: #202124; }
nav a { margin-right: 1rem; }
:root {
color-scheme: dark;
--bg: #101214;
--panel: #171a1f;
--panel-strong: #20242b;
--text: #eef1f4;
--muted: #a5adb7;
--line: #303640;
--accent: #62b3ff;
--accent-soft: rgba(98, 179, 255, 0.14);
--danger: #ff7d7d;
}
:root[data-theme="light"] {
color-scheme: light;
--bg: #f6f7f9;
--panel: #ffffff;
--panel-strong: #eef1f5;
--text: #1d232b;
--muted: #5f6b78;
--line: #d9dee5;
--accent: #0369a1;
--accent-soft: rgba(3, 105, 161, 0.1);
--danger: #b42318;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.45;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 3.75rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
.brand { color: var(--text); font-size: 1rem; font-weight: 700; }
.global-nav { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.global-nav a,
.theme-toggle {
min-height: 2rem;
border: 1px solid transparent;
border-radius: 6px;
padding: 0.35rem 0.6rem;
color: var(--muted);
background: transparent;
font: inherit;
}
.global-nav a[aria-current="page"] {
color: var(--text);
border-color: var(--line);
background: var(--panel-strong);
}
.theme-toggle { cursor: pointer; border-color: var(--line); }
main { padding: 1.25rem; }
h1, h2, h3 { margin: 0; line-height: 1.2; letter-spacing: 0; }
h1 { font-size: 1.6rem; }
h2 { font-size: 1.05rem; }
p { margin: 0; }
table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
th, td { border: 1px solid #ddd; padding: 0.45rem; text-align: left; vertical-align: top; }
th { background: #f5f5f5; }
.cards { display: flex; flex-wrap: wrap; gap: 1rem; }
.card { border: 1px solid #ddd; padding: 1rem; min-width: 10rem; }
.muted { color: #666; }
th, td {
border-bottom: 1px solid var(--line);
padding: 0.65rem 0.5rem;
text-align: left;
vertical-align: top;
}
th { color: var(--muted); font-size: 0.78rem; font-weight: 700; text-transform: uppercase; }
input, select {
width: 100%;
min-height: 2.25rem;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.4rem 0.55rem;
color: var(--text);
background: var(--panel);
font: inherit;
}
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 0.8rem; }
.card {
border: 1px solid var(--line);
border-radius: 8px;
padding: 1rem;
background: var(--panel);
}
.metric { display: block; font-size: 1.45rem; font-weight: 750; }
.muted { color: var(--muted); }
.toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; margin: 1rem 0; }
.toolbar label { display: grid; gap: 0.25rem; min-width: 12rem; color: var(--muted); font-size: 0.83rem; }
.button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.25rem;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.35rem 0.75rem;
color: var(--text);
background: var(--panel-strong);
font: inherit;
cursor: pointer;
}
.app-shell {
display: grid;
grid-template-columns: minmax(15rem, 21rem) minmax(0, 1fr);
gap: 1rem;
min-height: calc(100vh - 6.25rem);
}
.sidebar,
.detail {
min-width: 0;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
.sidebar { padding: 0.85rem; }
.sidebar-header { display: grid; gap: 0.75rem; margin-bottom: 0.75rem; }
.newsletter-list { display: grid; gap: 0.45rem; }
.newsletter-item {
display: grid;
gap: 0.35rem;
border: 1px solid transparent;
border-radius: 8px;
padding: 0.65rem;
color: var(--text);
background: transparent;
}
.newsletter-item[aria-current="true"] {
border-color: var(--accent);
background: var(--accent-soft);
}
.newsletter-item:hover { border-color: var(--line); text-decoration: none; }
.newsletter-title { font-weight: 700; }
.newsletter-meta { display: flex; flex-wrap: wrap; gap: 0.45rem; color: var(--muted); font-size: 0.82rem; }
.detail { overflow: hidden; }
.detail-header {
display: grid;
gap: 0.75rem;
padding: 1rem;
border-bottom: 1px solid var(--line);
background: var(--panel-strong);
}
.detail-title-row {
display: flex;
flex-wrap: wrap;
align-items: start;
justify-content: space-between;
gap: 0.75rem;
}
.counts { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.count-pill {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.25rem 0.55rem;
color: var(--muted);
font-size: 0.82rem;
white-space: nowrap;
}
.segmented {
display: inline-flex;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 7px;
width: fit-content;
}
.segmented a {
min-width: 6.5rem;
padding: 0.45rem 0.65rem;
color: var(--muted);
text-align: center;
}
.segmented a[aria-current="true"] {
color: var(--text);
background: var(--accent-soft);
}
.detail-body { padding: 1rem; overflow-x: auto; }
.link-title { font-weight: 700; }
.description { max-width: 36rem; color: var(--muted); }
.url-cell { max-width: 20rem; overflow-wrap: anywhere; }
.empty {
border: 1px dashed var(--line);
border-radius: 8px;
padding: 1.25rem;
color: var(--muted);
text-align: center;
}
@media (max-width: 800px) {
.topbar { align-items: flex-start; flex-direction: column; }
main { padding: 0.75rem; }
.app-shell { grid-template-columns: 1fr; }
.sidebar { max-height: 18rem; overflow: auto; }
.toolbar label { min-width: 100%; }
}
</style>
</head>
<body>
<nav>
<a href="/">Dashboard</a>
<a href="/links">Links</a>
<a href="/sponsors">Sponsored Links</a>
<a href="/dead-links">Dead Links</a>
<a href="/runs">Runs</a>
</nav>
${body}
<header class="topbar">
<a class="brand" href="/">Newsletter Link Catalog</a>
<nav class="global-nav" aria-label="Primary">
${navLink('/', 'Dashboard', 'dashboard', active)}
${navLink('/newsletters', 'Newsletters', 'newsletters', active)}
${navLink('/links', 'All Links', 'links', active)}
${navLink('/sponsors', 'Sponsored Links', 'sponsors', active)}
${navLink('/dead-links', 'Dead Links', 'dead-links', active)}
${navLink('/runs', 'Runs', 'runs', active)}
</nav>
<button class="theme-toggle" type="button" data-theme-toggle>Theme</button>
</header>
<main>${body}</main>
<script>
(() => {
const root = document.documentElement;
const savedTheme = localStorage.getItem('nlc-theme');
if (savedTheme) root.dataset.theme = savedTheme;
document.querySelector('[data-theme-toggle]')?.addEventListener('click', () => {
const next = root.dataset.theme === 'light' ? '' : 'light';
if (next) {
root.dataset.theme = next;
localStorage.setItem('nlc-theme', next);
} else {
root.removeAttribute('data-theme');
localStorage.removeItem('nlc-theme');
}
});
const search = document.querySelector('[data-newsletter-search]');
search?.addEventListener('input', () => {
const term = search.value.toLowerCase();
document.querySelectorAll('[data-newsletter-name]').forEach((item) => {
item.hidden = !item.dataset.newsletterName.includes(term);
});
});
})();
</script>
</body>
</html>`;
}
@@ -51,13 +309,148 @@ export function dashboard(counts: Record<string, number>): string {
return page(
'Newsletter Link Catalog',
`<h1>Newsletter Link Catalog</h1>
<section class="cards">
<section class="cards" aria-label="Catalog totals">
${Object.entries(counts)
.map(
([key, value]) =>
`<div class="card"><strong>${escapeHtml(value)}</strong><br>${escapeHtml(key)}</div>`
`<div class="card"><span class="metric">${escapeHtml(value)}</span><span class="muted">${escapeHtml(key)}</span></div>`
)
.join('')}
</section>`
</section>`,
'dashboard'
);
}
export function newsletterBrowser(model: NewsletterBrowserModel): string {
const { selected } = model;
const selectedId = selected?.id ?? '';
return page(
'Newsletters',
`<div class="app-shell">
<aside class="sidebar" aria-label="Newsletters">
<div class="sidebar-header">
<h1>Newsletters</h1>
<input type="search" placeholder="Search newsletters" aria-label="Search newsletters" data-newsletter-search>
</div>
<div class="newsletter-list">
${model.newsletters.length === 0 ? '<p class="muted">No newsletters yet.</p>' : ''}
${model.newsletters
.map((newsletter) =>
newsletterListItem(newsletter, model.filters, newsletter.id === selected?.id)
)
.join('')}
</div>
</aside>
<section class="detail" aria-label="Selected newsletter links">
${selected ? selectedNewsletterView(model, selectedId) : '<div class="detail-body"><p class="empty">No newsletter selected.</p></div>'}
</section>
</div>`,
'newsletters'
);
}
function newsletterListItem(
newsletter: NewsletterSummary,
filters: NewsletterBrowserModel['filters'],
isSelected: boolean
): string {
const href = pageUrl('/newsletters', {
newsletter: newsletter.id,
scope: filters.scope,
q: filters.linkSearch,
category: filters.category
});
return `<a class="newsletter-item" href="${escapeAttr(href)}" data-newsletter-name="${escapeAttr(newsletter.name.toLowerCase())}" aria-current="${isSelected}">
<span class="newsletter-title">${escapeHtml(newsletter.name)}</span>
<span class="newsletter-meta">
<span>${escapeHtml(newsletter.latestIssueDate || 'No issues')}</span>
<span>${escapeHtml(newsletter.issueCount)} issues</span>
<span>${escapeHtml(newsletter.linkCount)} links</span>
</span>
</a>`;
}
function selectedNewsletterView(
model: NewsletterBrowserModel,
selectedId: number | string
): string {
const { filters, selected } = model;
const latestHref = pageUrl('/newsletters', {
newsletter: selectedId,
scope: 'latest',
q: filters.linkSearch,
category: filters.category
});
const allHref = pageUrl('/newsletters', {
newsletter: selectedId,
scope: 'all',
q: filters.linkSearch,
category: filters.category
});
return `<div class="detail-header">
<div class="detail-title-row">
<div>
<h1>${escapeHtml(selected?.name)}</h1>
<p class="muted">Latest issue ${escapeHtml(selected?.latestIssueDate || 'not available')}</p>
</div>
<div class="counts" aria-label="Newsletter counts">
<span class="count-pill">${escapeHtml(selected?.issueCount)} issues</span>
<span class="count-pill">${escapeHtml(selected?.linkCount)} links</span>
<span class="count-pill">${escapeHtml(selected?.sponsorCount)} sponsored</span>
</div>
</div>
<div class="segmented" aria-label="Issue scope">
<a href="${escapeAttr(latestHref)}" aria-current="${filters.scope === 'latest'}">Latest Issue</a>
<a href="${escapeAttr(allHref)}" aria-current="${filters.scope === 'all'}">All Issues</a>
</div>
<form class="toolbar" method="get" action="/newsletters">
<input type="hidden" name="newsletter" value="${escapeAttr(selectedId)}">
<input type="hidden" name="scope" value="${escapeAttr(filters.scope)}">
<label>Search links
<input type="search" name="q" value="${escapeAttr(filters.linkSearch)}">
</label>
<label>Category
<select name="category">
<option value="">All categories</option>
${model.categories
.map(
(category) =>
`<option value="${escapeAttr(category)}"${category === filters.category ? ' selected' : ''}>${escapeHtml(category)}</option>`
)
.join('')}
</select>
</label>
<button class="button" type="submit">Apply</button>
</form>
</div>
<div class="detail-body">
${linksTable(model.links, filters.scope)}
</div>`;
}
function linksTable(rows: Record<string, unknown>[], scope: 'latest' | 'all'): string {
if (rows.length === 0) {
return '<p class="empty">No links match the current filters.</p>';
}
const issueHeader = scope === 'all' ? '<th>Issue Date</th>' : '';
return `<table>
<thead>
<tr>${issueHeader}<th>Title</th><th>Category</th><th>Description</th><th>URL</th><th>Also In</th></tr>
</thead>
<tbody>
${rows.map((row) => linkRow(row, scope)).join('')}
</tbody>
</table>`;
}
function linkRow(row: Record<string, unknown>, scope: 'latest' | 'all'): string {
const issueDate = scope === 'all' ? `<td>${escapeHtml(row.issueDate)}</td>` : '';
return `<tr>
${issueDate}
<td><a class="link-title" href="${escapeAttr(row.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(row.title || row.url)}</a></td>
<td>${escapeHtml(row.category)}</td>
<td class="description">${escapeHtml(row.description)}</td>
<td class="url-cell"><a href="${escapeAttr(row.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(row.url)}</a></td>
<td>${escapeHtml(row.alsoIn)}</td>
</tr>`;
}
+88 -2
View File
@@ -33,12 +33,22 @@ function fixtureDatabase(): string {
const db = new CatalogDatabase(path);
db.saveCatalogRun({
mode: 'test',
newslettersProcessed: 1,
linksExtracted: 1,
newslettersProcessed: 2,
linksExtracted: 3,
sponsorCount: 1,
deadLinkCount: 1,
errors: 0,
rows: [
{
'Issue Date': '2026-05-10',
Category: 'SQLite',
'Link URL': 'https://sqlite.example/old',
Title: 'Older SQLite Post',
Description: 'An older database post',
'Page Title + Meta': '',
'Source Newsletter': 'DB Weekly',
'Also In': ''
},
{
'Issue Date': '2026-05-17',
Category: 'SQLite',
@@ -48,6 +58,16 @@ function fixtureDatabase(): string {
'Page Title + Meta': '',
'Source Newsletter': 'DB Weekly',
'Also In': ''
},
{
'Issue Date': '2026-05-16',
Category: 'JavaScript',
'Link URL': 'https://js.example',
Title: 'JS Post',
Description: 'A JavaScript post',
'Page Title + Meta': '',
'Source Newsletter': 'JS Weekly',
'Also In': ''
}
],
sponsors: [
@@ -67,13 +87,79 @@ function fixtureDatabase(): string {
}
describe('web app', () => {
it('provides newsletter summaries and latest/all issue link scopes', () => {
const path = fixtureDatabase();
const db = new CatalogDatabase(path);
try {
const summaries = db.newsletterSummaries();
expect(
summaries.map(({ name, latestIssueDate, issueCount, linkCount, sponsorCount }) => ({
name,
latestIssueDate,
issueCount,
linkCount,
sponsorCount
}))
).toEqual([
{
name: 'DB Weekly',
latestIssueDate: '2026-05-17',
issueCount: 2,
linkCount: 2,
sponsorCount: 1
},
{
name: 'JS Weekly',
latestIssueDate: '2026-05-16',
issueCount: 1,
linkCount: 1,
sponsorCount: 0
}
]);
const dbWeekly = summaries.find((newsletter) => newsletter.name === 'DB Weekly');
expect(
db.newsletterLinks(dbWeekly?.id ?? 0, { scope: 'latest' }).map((link) => link.title)
).toEqual(['SQLite Post']);
expect(
db.newsletterLinks(dbWeekly?.id ?? 0, { scope: 'all' }).map((link) => link.title)
).toEqual(['SQLite Post', 'Older SQLite Post']);
} finally {
db.close();
}
});
it('renders dashboard and catalog pages from SQLite', async () => {
const path = fixtureDatabase();
await expect(withServer(path, '/')).resolves.toContain('Newsletter Link Catalog');
await expect(withServer(path, '/links')).resolves.toContain('SQLite Post');
await expect(withServer(path, '/links?q=JavaScript')).resolves.toContain('JS Post');
await expect(withServer(path, '/links?q=JavaScript')).resolves.not.toContain('SQLite Post');
await expect(withServer(path, '/sponsors')).resolves.toContain('Acme');
await expect(withServer(path, '/dead-links')).resolves.toContain('https://dead.example');
await expect(withServer(path, '/runs')).resolves.toContain('test');
});
it('renders the two-pane newsletter browser with latest issue by default and all issue mode', async () => {
const path = fixtureDatabase();
const latest = await withServer(path, '/newsletters');
expect(latest).toContain('class="app-shell"');
expect(latest).toContain('Search newsletters');
expect(latest).toContain('aria-current="true"');
expect(latest).toContain('Latest Issue');
expect(latest).toContain('All Issues');
expect(latest).toContain('SQLite Post');
expect(latest).not.toContain('Older SQLite Post');
expect(latest).toContain('target="_blank"');
expect(latest).toContain('rel="noopener noreferrer"');
const allIssues = await withServer(path, '/newsletters?scope=all');
expect(allIssues).toContain('SQLite Post');
expect(allIssues).toContain('Older SQLite Post');
expect(allIssues).toContain('2026-05-10');
expect(allIssues).toContain('name="category"');
expect(allIssues).toContain('name="q"');
});
});