diff --git a/bindery/static/js/stories.js b/bindery/static/js/stories.js index 10d4ecb..0885565 100644 --- a/bindery/static/js/stories.js +++ b/bindery/static/js/stories.js @@ -3,6 +3,16 @@ import { jobPanel, refreshJobs, watchJobs } from './jobs.js'; import { api, badge, button, checkbox, fill, fmtDate, h, link, showError, toast } from './lib.js'; +const STORY_COLUMNS = [ + { label: 'Title', key: 'title', type: 'text' }, + { label: 'Author', key: 'author', type: 'text' }, + { label: 'Site', key: 'site', type: 'text' }, + { label: 'Chapters', key: 'chapter_count', type: 'number' }, + { label: 'Status', key: 'status', type: 'text' }, + { label: 'Last change', key: 'last_changed', type: 'date' }, + { label: 'Last check', key: 'last_checked', type: 'date' }, +]; + function libraryImport(config) { if (!config.library_available) { return h('p', { class: 'hint' }, 'Set CWA_URL to import books from the library.'); @@ -199,24 +209,64 @@ function storyRow(s) { ); } +function sortTitle(title) { + return title.trim().replace(/^(?:the|an?)\s+/i, ''); +} + export function storiesPage(main, _params, config) { document.title = 'Stories · Bindery'; let stories = null; let filter = ''; + let sort = 'title'; + let direction = 1; + const sortButtons = new Map(); const table = h('div', {}, h('p', { class: 'empty' }, 'Loading…')); const jobs = jobPanel({ hours: config.main_jobs_hours, count: config.main_jobs_count }); const render = () => { + const focusedSort = [...sortButtons].find(([, control]) => control === document.activeElement)?.[0]; const q = filter.trim().toLowerCase(); const shown = q ? stories.filter((s) => [s.title, s.author, s.site, s.url].some((v) => v?.toLowerCase().includes(q))) - : stories; + : [...stories]; + const sortType = STORY_COLUMNS.find(({ key }) => key === sort).type; + shown.sort((a, b) => { + if (sortType === 'date') { + const left = Date.parse(a[sort]); + const right = Date.parse(b[sort]); + if (Number.isNaN(left) !== Number.isNaN(right)) return Number.isNaN(left) ? 1 : -1; + const order = (left - right) * direction; + if (order) return order; + } else if (sort !== 'title') { + const order = sortType === 'number' + ? a[sort] - b[sort] + : (a[sort] || '').localeCompare(b[sort] || '', undefined, { sensitivity: 'base' }); + if (order) return order * direction; + } + const order = sortTitle(a.title || a.url).localeCompare(sortTitle(b.title || b.url), undefined, { sensitivity: 'base' }) || a.id - b.id; + return sort === 'title' ? order * direction : order; + }); if (!shown.length) { const text = stories.length ? 'No stories match.' : 'No stories yet. Add a URL above, or import an ePub.'; fill(table, h('p', { class: 'empty' }, text)); return; } - const head = ['Title', 'Author', 'Site', 'Chapters', 'Status', 'Last change', 'Last check'].map((t) => h('th', {}, t)); + const heading = ({ label, key, type }) => { + const active = sort === key; + const nextDirection = active ? -direction : type === 'text' ? 1 : -1; + const orders = type === 'text' ? ['A to Z', 'Z to A'] + : type === 'date' ? ['oldest first', 'newest first'] : ['fewest first', 'most first']; + const action = `Sort by ${label.toLowerCase()}, ${orders[nextDirection === 1 ? 0 : 1]}`; + const control = button(label, () => { + sort = key; + direction = nextDirection; + render(); + sortButtons.get(key).focus(); + }, { title: action, 'aria-label': action }); + sortButtons.set(key, control); + return h('th', { scope: 'col', 'aria-sort': active ? direction === 1 ? 'ascending' : 'descending' : 'none' }, control); + }; + const head = STORY_COLUMNS.map(heading); fill( table, h( @@ -225,6 +275,7 @@ export function storiesPage(main, _params, config) { h('table', {}, h('thead', {}, h('tr', {}, head)), h('tbody', {}, shown.map(storyRow))), ), ); + if (focusedSort) sortButtons.get(focusedSort).focus(); }; const load = () => diff --git a/bindery/static/style.css b/bindery/static/style.css index f1c85d3..292dd50 100644 --- a/bindery/static/style.css +++ b/bindery/static/style.css @@ -745,6 +745,45 @@ textarea { overflow-x: auto; } +.stories-table th { + padding: 0; +} + +.stories-table th button { + inline-size: 100%; + justify-content: flex-start; + padding: 6px 8px; + border: 0; + border-radius: 4px; + background: none; + color: inherit; + font: inherit; +} + +.stories-table th button:is(:hover, :focus-visible) { + background: var(--info-bg); + color: var(--info); +} + +.stories-table th[aria-sort] button::after { + content: '↕'; + display: inline-block; + inline-size: 1em; + margin-inline-start: 0.3em; +} + +.stories-table th[aria-sort='ascending'] button::after { + content: '↑'; +} + +.stories-table th[aria-sort='descending'] button::after { + content: '↓'; +} + +.stories-table th[aria-sort]:not([aria-sort='none']) { + color: var(--text); +} + .stories-table td[data-label='Chapters'] small { display: block; white-space: nowrap; @@ -779,13 +818,16 @@ textarea { display: block; } - .stories-table thead { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - white-space: nowrap; - clip-path: inset(50%); + .stories-table thead tr { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + margin-bottom: 8px; + } + + .stories-table th { + padding: 4px 0; + border: 0; } .stories-table tbody tr, diff --git a/tests/js/layout.test.mjs b/tests/js/layout.test.mjs index dad6def..6545185 100644 --- a/tests/js/layout.test.mjs +++ b/tests/js/layout.test.mjs @@ -103,6 +103,33 @@ test('story timestamps fit their text and chapter details use plain small text', assert.equal(details.padding, undefined); }); +test('sortable headings show direction and remain usable above story cards on narrow screens', () => { + assert.equal(declarations('.stories-table th button').background, 'none'); + assert.equal(declarations('.stories-table th[aria-sort] button::after').content, "'↕'"); + assert.equal(declarations(".stories-table th[aria-sort='ascending'] button::after").content, "'↑'"); + assert.equal(declarations(".stories-table th[aria-sort='descending'] button::after").content, "'↓'"); + const mobile = css.slice(css.indexOf('@media (max-width: 600px)')); + assert.equal(declarations('.stories-table thead tr', mobile).display, 'flex'); + assert.notEqual(declarations('.stories-table th', mobile).display, 'none'); + assert.doesNotMatch(css, /\.stories-table thead\s*\{[^}]*clip-path/); +}); + +test('sortable headings fill the column with one clickable highlight without shifting layout', () => { + assert.equal(declarations('.stories-table th').padding, '0'); + const button = declarations('.stories-table th button'); + assert.equal(button['inline-size'], '100%'); + assert.equal(button['justify-content'], 'flex-start'); + assert.equal(button.padding, '6px 8px'); + assert.equal(button.margin, undefined); + const highlight = declarations('.stories-table th button:is(:hover, :focus-visible)'); + assert.equal(highlight.background, 'var(--info-bg)'); + assert.equal(highlight.color, 'var(--info)'); + assert.equal(highlight['text-decoration'], undefined); + assert.equal(highlight.padding, undefined); + assert.equal(highlight.border, undefined); + assert.doesNotMatch(css, /\.stories-table th button:hover\s*\{[^}]*underline/); +}); + 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 index 34fee03..b2e1a10 100644 --- a/tests/js/story-list.test.mjs +++ b/tests/js/story-list.test.mjs @@ -1,10 +1,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { install, nodes, server, settle } from './flow-helpers.mjs'; +import { click, field, find, input, 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 { refreshJobs, startPolling, stopPolling } = await import('../../bindery/static/js/jobs.js'); const { fmtDate } = await import('../../bindery/static/js/lib.js'); const story = (id, title, changes = {}) => ({ @@ -16,7 +16,9 @@ const rows = () => { const table = nodes(app).find((el) => el.className === 'table-wrap stories-table'); return nodes(table).find((el) => el.tagName === 'tbody').children; }; +const titles = () => rows().map((row) => row.children[0].textContent); const cell = (row, label) => row.children.find((el) => el.attributes['data-label'] === label); +const heading = (label) => nodes(app).find((el) => el.tagName === 'th' && el.textContent === label); async function mount(t, list) { const backend = server(t); @@ -28,6 +30,152 @@ async function mount(t, list) { return backend; } +test('column headings toggle sorting, with newest dates first and missing dates always last', async (t) => { + const backend = await mount(t, [ + story(3, 'Gamma', { last_changed: '2026-01-01T12:00:00Z', last_checked: '2026-01-03T12:00:00Z' }), + story(2, 'Beta', { last_changed: '2026-01-02T12:00:00Z', last_checked: '2026-01-02T12:00:00Z' }), + story(4, 'No date'), + story(1, 'Alpha', { last_changed: '2026-01-03T12:00:00Z', last_checked: '2026-01-01T12:00:00Z' }), + ]); + assert.equal(field(app, 'Sort stories by'), undefined); + assert.equal(heading('Title').attributes['aria-sort'], 'ascending'); + assert.equal(heading('Last change').attributes['aria-sort'], 'none'); + assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']); + assert.equal(find(app, 'button', 'Last change').attributes['aria-label'], 'Sort by last change, newest first'); + await click(app, 'Last change'); + assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']); + assert.equal(heading('Title').attributes['aria-sort'], 'none'); + assert.equal(heading('Last change').attributes['aria-sort'], 'descending'); + assert.equal(document.activeElement, find(app, 'button', 'Last change')); + assert.equal(find(app, 'button', 'Last change').attributes['aria-label'], 'Sort by last change, oldest first'); + await click(app, 'Last change'); + assert.deepEqual(titles(), ['Gamma', 'Beta', 'Alpha', 'No date']); + assert.equal(heading('Last change').attributes['aria-sort'], 'ascending'); + await click(app, 'Last check'); + assert.deepEqual(titles(), ['Gamma', 'Beta', 'Alpha', 'No date']); + assert.equal(heading('Last check').attributes['aria-sort'], 'descending'); + await click(app, 'Last check'); + assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']); + assert.equal(heading('Last check').attributes['aria-sort'], 'ascending'); + await click(app, 'Title'); + assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']); + await click(app, 'Title'); + assert.deepEqual(titles(), ['No date', 'Gamma', 'Beta', 'Alpha']); + assert.equal(heading('Title').attributes['aria-sort'], 'descending'); + await click(app, 'Last change'); + assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']); + assert.equal(backend.sent('GET', 'stories').length, 1); +}); + +test('title sorting and tie breaks keep ignoring leading articles', async (t) => { + await mount(t, ['The Zebra', 'an Owl', 'Anthem', 'A Bee', 'Theory', 'Cat'].map((title, i) => story(i + 1, title))); + const order = ['Anthem', 'A Bee', 'Cat', 'an Owl', 'Theory', 'The Zebra']; + assert.deepEqual(titles(), order); + await click(app, 'Title'); + assert.deepEqual(titles(), [...order].reverse()); + await click(app, 'Chapters'); + assert.deepEqual(titles(), order); +}); + +test('chapter sorting compares counts numerically and keeps equal counts in title order', async (t) => { + await mount(t, [ + story(1, 'Zeta', { chapter_count: 2, offsite_count: 2, revised_count: 1 }), + story(2, 'Alpha', { chapter_count: 10 }), + story(3, 'Beta', { chapter_count: 10 }), + story(4, 'Gamma', { chapter_count: 0 }), + story(5, 'Omega', { chapter_count: 100 }), + ]); + assert.equal(find(app, 'button', 'Chapters').attributes['aria-label'], 'Sort by chapters, most first'); + await click(app, 'Chapters'); + assert.deepEqual(titles(), ['Omega', 'Alpha', 'Beta', 'Zeta', 'Gamma']); + assert.equal(heading('Chapters').attributes['aria-sort'], 'descending'); + await click(app, 'Chapters'); + assert.deepEqual(titles(), ['Gamma', 'Zeta', 'Alpha', 'Beta', 'Omega']); + assert.equal(heading('Chapters').attributes['aria-sort'], 'ascending'); +}); + +for (const [label, key] of [['Author', 'author'], ['Site', 'site'], ['Status', 'status']]) { + test(`${label} sorts alphabetically in either direction and uses titles to break ties`, async (t) => { + await mount(t, [ + story(1, 'Zeta', { [key]: 'ok' }), + story(3, 'Beta', { [key]: 'error' }), + story(2, 'Alpha', { [key]: 'ERROR' }), + story(4, 'Gamma', { [key]: null }), + story(5, 'Omega', { [key]: 'queued' }), + ]); + await click(app, label); + assert.deepEqual(titles(), ['Gamma', 'Alpha', 'Beta', 'Zeta', 'Omega']); + assert.equal(heading(label).attributes['aria-sort'], 'ascending'); + await click(app, label); + assert.deepEqual(titles(), ['Omega', 'Zeta', 'Alpha', 'Beta', 'Gamma']); + assert.equal(heading(label).attributes['aria-sort'], 'descending'); + }); +} + +test('date sorting compares instants, handles invalid dates and breaks ties by title and id', async (t) => { + await mount(t, [ + story(7, 'Missing', { last_changed: 'not a date' }), + story(6, 'Unknown'), + story(5, 'Epoch', { last_changed: '1970-01-01T00:00:00Z' }), + story(4, 'Before epoch', { last_changed: '1969-01-01T00:00:00Z' }), + story(3, 'Zulu', { last_changed: '2026-01-02T00:00:00+02:00' }), + story(2, 'alpha', { last_changed: '2026-01-01T23:00:00Z' }), + story(1, 'Alpha', { last_changed: '2026-01-01T23:00:00Z' }), + story(8, '', { last_changed: '2026-01-01T23:00:00Z' }), + ]); + await click(app, 'Last change'); + assert.deepEqual(titles(), ['Alpha', 'alpha', 'https://example.com/8', 'Zulu', 'Epoch', 'Before epoch', 'Missing', 'Unknown']); + assert.equal(cell(rows()[6], 'Last change').textContent, 'not a date'); + assert.equal(cell(rows()[7], 'Last change').textContent, ''); + await click(app, 'Last change'); + assert.deepEqual(titles(), ['Before epoch', 'Epoch', 'Zulu', 'Alpha', 'alpha', 'https://example.com/8', 'Missing', 'Unknown']); +}); + +test('headings sort the filtered list after the initial story request finishes', async (t) => { + let resolve; + await mount(t, () => new Promise((done) => { resolve = done; })); + assert.equal(find(app, 'button', 'Last check'), undefined); + input(field(app, 'Filter stories'), 'Reader'); + resolve([ + story(1, 'Alpha', { last_checked: '2026-01-01T00:00:00Z' }), + story(2, 'Beta', { last_checked: '2026-01-02T00:00:00Z' }), + ]); + await settle(); + await click(app, 'Last check'); + assert.deepEqual(titles(), ['Beta', 'Alpha']); +}); + +test('filtering and job refreshes keep the selected sort without dropping hidden stories', async (t) => { + let list = [ + story(1, 'Alpha', { last_changed: '2026-01-01T00:00:00Z' }), + story(2, 'Beta', { last_changed: '2026-01-02T00:00:00Z' }), + story(3, 'Gamma', { last_changed: '2026-01-03T00:00:00Z', author: 'Someone else' }), + ]; + const backend = await mount(t, () => list); + await click(app, 'Last change'); + input(field(app, 'Filter stories'), 'Reader'); + assert.deepEqual(titles(), ['Beta', 'Alpha']); + backend.on('GET', 'jobs', [{ id: 'job', story_id: 1, status: 'running' }]); + startPolling(); + await settle(); + list = [{ ...list[0], last_changed: '2026-01-04T00:00:00Z' }, ...list.slice(1)]; + backend.on('GET', 'jobs', [{ id: 'job', story_id: 1, status: 'done' }]); + await refreshJobs(); + await settle(); + assert.equal(backend.sent('GET', 'stories').length, 2); + assert.equal(heading('Last change').attributes['aria-sort'], 'descending'); + assert.equal(document.activeElement, find(app, 'button', 'Last change')); + assert.deepEqual(titles(), ['Alpha', 'Beta']); + input(field(app, 'Filter stories'), 'missing'); + assert.match(app.textContent, /No stories match/); + input(field(app, 'Filter stories'), ''); + assert.deepEqual(titles(), ['Alpha', 'Gamma', 'Beta']); + await click(app, 'Last check'); + assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma']); + await click(app, 'Last change'); + assert.deepEqual(titles(), ['Alpha', 'Gamma', 'Beta']); +}); + 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';