From b536a1f1165b0045ac4049c432f2ef27cbc86375 Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Thu, 1 Oct 2026 15:42:48 +0200 Subject: [PATCH] Show story change times and compact list details --- bindery/static/js/stories.js | 27 ++++++++---- bindery/static/style.css | 11 +++++ tests/js/flows.test.mjs | 2 +- tests/js/layout.test.mjs | 16 +++++++ tests/js/story-list.test.mjs | 81 ++++++++++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 tests/js/story-list.test.mjs 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'); +});