diff --git a/bindery/static/js/stories.js b/bindery/static/js/stories.js
index bdc5101..10d4ecb 100644
--- a/bindery/static/js/stories.js
+++ b/bindery/static/js/stories.js
@@ -160,7 +160,21 @@ export function epubImport(storyId, title = 'Import an ePub file') {
return card;
}
+function storyDate(label, value) {
+ const date = new Date(value);
+ let content = fmtDate(value);
+ if (value && !Number.isNaN(date.getTime())) {
+ content = h('time', { datetime: value, title: content }, content);
+ }
+ return h('td', { class: 'date', 'data-label': label }, content);
+}
+
function storyRow(s) {
+ const status = s.status && badge(s.status, s.status, s.message);
+ if (status && s.message) {
+ status.setAttribute('tabindex', '0');
+ status.setAttribute('aria-description', s.message);
+ }
return h(
'tr',
{},
@@ -171,18 +185,17 @@ function storyRow(s) {
'td',
{ 'data-label': 'Chapters' },
String(s.chapter_count),
- s.offsite_count > 0 && [' ', badge('warn', `${s.offsite_count} removed`, 'Chapters the site no longer lists')],
- s.revised_count > 0 && [' ', badge('info', `${s.revised_count} revised`, 'Chapters the site changed since Bindery added them')],
+ s.offsite_count > 0 && h('small', { title: 'Chapters the site no longer lists' }, `${s.offsite_count} removed`),
+ s.revised_count > 0 && h('small', { title: 'Chapters the site changed since Bindery added them' }, `${s.revised_count} revised`),
),
h(
'td',
{ 'data-label': 'Status' },
- s.status && badge(s.status),
+ status,
s.upload_held && [' ', badge('warn', 'held', 'The book did not go to CWA. See the story page.')],
- ' ',
- h('small', {}, s.message),
),
- h('td', { class: 'date', 'data-label': 'Last check' }, fmtDate(s.last_checked)),
+ storyDate('Last change', s.last_changed),
+ storyDate('Last check', s.last_checked),
);
}
@@ -203,7 +216,7 @@ export function storiesPage(main, _params, config) {
fill(table, h('p', { class: 'empty' }, text));
return;
}
- const head = ['Title', 'Author', 'Site', 'Chapters', 'Status', 'Last check'].map((t) => h('th', {}, t));
+ const head = ['Title', 'Author', 'Site', 'Chapters', 'Status', 'Last change', 'Last check'].map((t) => h('th', {}, t));
fill(
table,
h(
diff --git a/bindery/static/style.css b/bindery/static/style.css
index 2ca4b4d..f1c85d3 100644
--- a/bindery/static/style.css
+++ b/bindery/static/style.css
@@ -745,6 +745,17 @@ textarea {
overflow-x: auto;
}
+.stories-table td[data-label='Chapters'] small {
+ display: block;
+ white-space: nowrap;
+}
+
+@media (min-width: 601px) {
+ .stories-table .date {
+ inline-size: 1%;
+ }
+}
+
/* The story list and the chapter list stack into cards below 600px, instead of scrolling
sideways. data-label repeats each column heading in front of its value. */
@media (max-width: 600px) {
diff --git a/tests/js/flows.test.mjs b/tests/js/flows.test.mjs
index de93d44..8ab1cf3 100644
--- a/tests/js/flows.test.mjs
+++ b/tests/js/flows.test.mjs
@@ -289,7 +289,7 @@ test('story list stacks on a narrow screen instead of scrolling sideways', async
const wrap = nodes(main).find((el) => el.className === 'table-wrap stories-table');
assert.ok(wrap);
const dataLabels = nodes(wrap).filter((el) => el.tagName === 'td').map((el) => el.attributes['data-label']);
- assert.deepEqual(dataLabels, [undefined, 'Author', 'Site', 'Chapters', 'Status', 'Last check']);
+ assert.deepEqual(dataLabels, [undefined, 'Author', 'Site', 'Chapters', 'Status', 'Last change', 'Last check']);
});
test('the story list shows removed and revised chapter counts, only when there are any', async () => {
diff --git a/tests/js/layout.test.mjs b/tests/js/layout.test.mjs
index cf79d93..dad6def 100644
--- a/tests/js/layout.test.mjs
+++ b/tests/js/layout.test.mjs
@@ -87,6 +87,22 @@ test('the Add stories textarea uses the smaller text size without shrinking othe
assert.equal(declarations('textarea')['font-size'], undefined);
});
+test('story timestamps fit their text and chapter details use plain small text', () => {
+ const desktop = css.slice(css.indexOf('@media (min-width: 601px)'));
+ const date = declarations('.date');
+ assert.equal(date['white-space'], 'nowrap');
+ assert.equal(date['min-inline-size'], undefined);
+ const storyDate = declarations('.stories-table .date', desktop);
+ assert.equal(storyDate['inline-size'], '1%');
+ assert.equal(storyDate['min-inline-size'], undefined);
+ const details = declarations(".stories-table td[data-label='Chapters'] small");
+ assert.equal(details.display, 'block');
+ assert.equal(details['white-space'], 'nowrap');
+ assert.equal(details.background, undefined);
+ assert.equal(details.border, undefined);
+ assert.equal(details.padding, undefined);
+});
+
function luminance(hex) {
const channels = hex.slice(1).match(/../g).map((channel) => {
const value = parseInt(channel, 16) / 255;
diff --git a/tests/js/story-list.test.mjs b/tests/js/story-list.test.mjs
new file mode 100644
index 0000000..34fee03
--- /dev/null
+++ b/tests/js/story-list.test.mjs
@@ -0,0 +1,81 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { install, nodes, server, settle } from './flow-helpers.mjs';
+
+const { app } = install();
+const { storiesPage } = await import('../../bindery/static/js/stories.js');
+const { stopPolling } = await import('../../bindery/static/js/jobs.js');
+const { fmtDate } = await import('../../bindery/static/js/lib.js');
+
+const story = (id, title, changes = {}) => ({
+ id, title, url: `https://example.com/${id}`, author: 'Reader', site: 'example.com',
+ chapter_count: 42, offsite_count: 0, revised_count: 0, status: 'ok', message: 'No changes',
+ last_changed: null, last_checked: null, ...changes,
+});
+const rows = () => {
+ const table = nodes(app).find((el) => el.className === 'table-wrap stories-table');
+ return nodes(table).find((el) => el.tagName === 'tbody').children;
+};
+const cell = (row, label) => row.children.find((el) => el.attributes['data-label'] === label);
+
+async function mount(t, list) {
+ const backend = server(t);
+ backend.on('GET', 'stories', list);
+ backend.on('GET', 'jobs', []);
+ const cleanup = storiesPage(app, {}, { library_available: false, main_jobs_hours: 24, main_jobs_count: 10 });
+ t.after(() => { cleanup(); stopPolling(); });
+ await settle();
+ return backend;
+}
+
+test('date columns keep each timestamp on one line, with last change before last check', async (t) => {
+ const changed = '2026-01-02T03:04:00Z';
+ const checked = '2026-01-03T05:06:00Z';
+ await mount(t, [story(1, 'Alpha', { last_changed: changed, last_checked: checked })]);
+ const headings = nodes(app).filter((el) => el.tagName === 'th').map((el) => el.textContent);
+ assert.deepEqual(headings, ['Title', 'Author', 'Site', 'Chapters', 'Status', 'Last change', 'Last check']);
+ for (const [label, value] of [['Last change', changed], ['Last check', checked]]) {
+ const dateCell = cell(rows()[0], label);
+ const time = nodes(dateCell).find((el) => el.tagName === 'time');
+ assert.equal(time.attributes.datetime, value);
+ assert.equal(time.attributes.title, fmtDate(value));
+ assert.equal(nodes(time).filter((el) => el.tagName === 'br').length, 0);
+ assert.equal(time.textContent, fmtDate(value));
+ }
+});
+
+test('status messages move into accessible pill descriptions without hiding held or chapter counts', async (t) => {
+ await mount(t, [
+ story(1, 'Alpha', { offsite_count: 3, revised_count: 2, message: 'Updated 2 chapters', upload_held: true }),
+ story(2, 'Beta', { status: 'error', message: '' }),
+ story(3, 'Gamma'),
+ story(4, 'New', { status: '', message: '' }),
+ story(5, 'Queued', { status: 'queued', message: '' }),
+ ]);
+ for (const [row, label, message] of [
+ [rows()[0], 'ok', 'Updated 2 chapters'],
+ [rows()[1], 'error', ''],
+ [rows()[2], 'ok', 'No changes'],
+ ]) {
+ const status = cell(row, 'Status');
+ const pill = status.children[0];
+ assert.equal(pill.textContent, label);
+ assert.equal(pill.attributes.title, message);
+ assert.equal(pill.attributes['aria-description'], message);
+ assert.equal(pill.attributes.tabindex, '0');
+ assert.ok(!status.textContent.includes(message));
+ assert.equal(nodes(status).filter((el) => el.tagName === 'small' || el.tagName === 'script').length, 0);
+ }
+ assert.match(cell(rows()[0], 'Status').textContent, /held/);
+ const chapters = cell(rows()[0], 'Chapters');
+ assert.match(chapters.textContent, /^42/);
+ assert.equal(nodes(chapters).filter((el) => el.className.startsWith('badge')).length, 0);
+ const counts = nodes(chapters).filter((el) => el.tagName === 'small');
+ assert.deepEqual(counts.map((el) => el.textContent), ['3 removed', '2 revised']);
+ assert.deepEqual(counts.map((el) => el.attributes.title), [
+ 'Chapters the site no longer lists', 'Chapters the site changed since Bindery added them',
+ ]);
+ assert.equal(cell(rows()[1], 'Chapters').textContent, '42');
+ assert.equal(cell(rows()[3], 'Status').textContent, '');
+ assert.equal(cell(rows()[4], 'Status').textContent, 'queued');
+});