Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34ff432aac | ||
|
|
e4a17029c1 | ||
|
|
b536a1f116 | ||
|
|
13d7360f60 |
@@ -3,6 +3,17 @@
|
|||||||
import { jobPanel, refreshJobs, watchJobs } from './jobs.js';
|
import { jobPanel, refreshJobs, watchJobs } from './jobs.js';
|
||||||
import { api, badge, button, checkbox, fill, fmtDate, h, link, showError, toast } from './lib.js';
|
import { api, badge, button, checkbox, fill, fmtDate, h, link, showError, toast } from './lib.js';
|
||||||
|
|
||||||
|
const STORY_SORT_KEY = 'bindery.story-sort';
|
||||||
|
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) {
|
function libraryImport(config) {
|
||||||
if (!config.library_available) {
|
if (!config.library_available) {
|
||||||
return h('p', { class: 'hint' }, 'Set CWA_URL to import books from the library.');
|
return h('p', { class: 'hint' }, 'Set CWA_URL to import books from the library.');
|
||||||
@@ -160,7 +171,21 @@ export function epubImport(storyId, title = 'Import an ePub file') {
|
|||||||
return card;
|
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) {
|
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(
|
return h(
|
||||||
'tr',
|
'tr',
|
||||||
{},
|
{},
|
||||||
@@ -171,39 +196,92 @@ function storyRow(s) {
|
|||||||
'td',
|
'td',
|
||||||
{ 'data-label': 'Chapters' },
|
{ 'data-label': 'Chapters' },
|
||||||
String(s.chapter_count),
|
String(s.chapter_count),
|
||||||
s.offsite_count > 0 && [' ', badge('warn', `${s.offsite_count} removed`, 'Chapters the site no longer lists')],
|
s.offsite_count > 0 && h('small', { title: 'Chapters the site no longer lists' }, `${s.offsite_count} removed`),
|
||||||
s.revised_count > 0 && [' ', badge('info', `${s.revised_count} revised`, 'Chapters the site changed since Bindery added them')],
|
s.revised_count > 0 && h('small', { title: 'Chapters the site changed since Bindery added them' }, `${s.revised_count} revised`),
|
||||||
),
|
),
|
||||||
h(
|
h(
|
||||||
'td',
|
'td',
|
||||||
{ 'data-label': 'Status' },
|
{ '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.')],
|
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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortTitle(title) {
|
||||||
|
return title.trim().replace(/^(?:the|an?)\s+/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
export function storiesPage(main, _params, config) {
|
export function storiesPage(main, _params, config) {
|
||||||
document.title = 'Stories · Bindery';
|
document.title = 'Stories · Bindery';
|
||||||
let stories = null;
|
let stories = null;
|
||||||
let filter = '';
|
let filter = '';
|
||||||
|
let sort = 'title';
|
||||||
|
let direction = 1;
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(window.localStorage.getItem(STORY_SORT_KEY));
|
||||||
|
if (saved && STORY_COLUMNS.some(({ key }) => key === saved.column) && [1, -1].includes(saved.direction)) {
|
||||||
|
sort = saved.column;
|
||||||
|
direction = saved.direction;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep the default if storage is blocked or its value is invalid.
|
||||||
|
}
|
||||||
|
const sortButtons = new Map();
|
||||||
const table = h('div', {}, h('p', { class: 'empty' }, 'Loading…'));
|
const table = h('div', {}, h('p', { class: 'empty' }, 'Loading…'));
|
||||||
const jobs = jobPanel({ hours: config.main_jobs_hours, count: config.main_jobs_count });
|
const jobs = jobPanel({ hours: config.main_jobs_hours, count: config.main_jobs_count });
|
||||||
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
|
const focusedSort = [...sortButtons].find(([, control]) => control === document.activeElement)?.[0];
|
||||||
const q = filter.trim().toLowerCase();
|
const q = filter.trim().toLowerCase();
|
||||||
const shown = q
|
const shown = q
|
||||||
? stories.filter((s) => [s.title, s.author, s.site, s.url].some((v) => v?.toLowerCase().includes(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) {
|
if (!shown.length) {
|
||||||
const text = stories.length ? 'No stories match.' : 'No stories yet. Add a URL above, or import an ePub.';
|
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));
|
fill(table, h('p', { class: 'empty' }, text));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const head = ['Title', 'Author', 'Site', 'Chapters', 'Status', '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;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORY_SORT_KEY, JSON.stringify({ column: sort, direction }));
|
||||||
|
} catch {
|
||||||
|
// Sorting still works when the browser blocks storage.
|
||||||
|
}
|
||||||
|
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(
|
fill(
|
||||||
table,
|
table,
|
||||||
h(
|
h(
|
||||||
@@ -212,6 +290,7 @@ export function storiesPage(main, _params, config) {
|
|||||||
h('table', {}, h('thead', {}, h('tr', {}, head)), h('tbody', {}, shown.map(storyRow))),
|
h('table', {}, h('thead', {}, h('tr', {}, head)), h('tbody', {}, shown.map(storyRow))),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (focusedSort) sortButtons.get(focusedSort).focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const load = () =>
|
const load = () =>
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export function storyPage(main, { storyId }, config) {
|
|||||||
story.status && badge(story.status),
|
story.status && badge(story.status),
|
||||||
` ${story.message || ''}`,
|
` ${story.message || ''}`,
|
||||||
story.last_checked && h('span', { class: 'last-check' }, ` Last check ${fmtDate(story.last_checked)}.`),
|
story.last_checked && h('span', { class: 'last-check' }, ` Last check ${fmtDate(story.last_checked)}.`),
|
||||||
|
story.last_changed && h('span', { class: 'last-update' }, ` Last update ${fmtDate(story.last_changed)}.`),
|
||||||
),
|
),
|
||||||
details.library_error && h('p', { class: 'error' }, `CWA: ${details.library_error}`),
|
details.library_error && h('p', { class: 'error' }, `CWA: ${details.library_error}`),
|
||||||
holdNotice(),
|
holdNotice(),
|
||||||
|
|||||||
@@ -466,6 +466,7 @@ label:has(> input[type='radio']) {
|
|||||||
|
|
||||||
.byline,
|
.byline,
|
||||||
.last-check,
|
.last-check,
|
||||||
|
.last-update,
|
||||||
.story-site,
|
.story-site,
|
||||||
.field-default,
|
.field-default,
|
||||||
small,
|
small,
|
||||||
@@ -744,6 +745,56 @@ textarea {
|
|||||||
overflow-x: auto;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
/* 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. */
|
sideways. data-label repeats each column heading in front of its value. */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
@@ -767,13 +818,16 @@ textarea {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stories-table thead {
|
.stories-table thead tr {
|
||||||
position: absolute;
|
display: flex;
|
||||||
width: 1px;
|
flex-wrap: wrap;
|
||||||
height: 1px;
|
gap: 8px 16px;
|
||||||
overflow: hidden;
|
margin-bottom: 8px;
|
||||||
white-space: nowrap;
|
}
|
||||||
clip-path: inset(50%);
|
|
||||||
|
.stories-table th {
|
||||||
|
padding: 4px 0;
|
||||||
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stories-table tbody tr,
|
.stories-table tbody tr,
|
||||||
|
|||||||
@@ -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');
|
const wrap = nodes(main).find((el) => el.className === 'table-wrap stories-table');
|
||||||
assert.ok(wrap);
|
assert.ok(wrap);
|
||||||
const dataLabels = nodes(wrap).filter((el) => el.tagName === 'td').map((el) => el.attributes['data-label']);
|
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 () => {
|
test('the story list shows removed and revised chapter counts, only when there are any', async () => {
|
||||||
|
|||||||
@@ -87,6 +87,49 @@ test('the Add stories textarea uses the smaller text size without shrinking othe
|
|||||||
assert.equal(declarations('textarea')['font-size'], undefined);
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
function luminance(hex) {
|
||||||
const channels = hex.slice(1).match(/../g).map((channel) => {
|
const channels = hex.slice(1).match(/../g).map((channel) => {
|
||||||
const value = parseInt(channel, 16) / 255;
|
const value = parseInt(channel, 16) / 255;
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { beforeEach, test } from 'node:test';
|
||||||
|
import { click, field, find, input, install, nodes, server, settle } from './flow-helpers.mjs';
|
||||||
|
|
||||||
|
const { app, toasts } = install();
|
||||||
|
const SORT_KEY = 'bindery.story-sort';
|
||||||
|
beforeEach(() => {
|
||||||
|
const values = new Map();
|
||||||
|
window.localStorage = {
|
||||||
|
getItem: (key) => values.get(key) ?? null,
|
||||||
|
setItem: (key, value) => values.set(key, value),
|
||||||
|
};
|
||||||
|
toasts.replaceChildren();
|
||||||
|
});
|
||||||
|
const { storiesPage } = await import('../../bindery/static/js/stories.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 = {}) => ({
|
||||||
|
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 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);
|
||||||
|
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('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('sorting saves the column and direction and restores both when the list reopens', async (t) => {
|
||||||
|
const list = [
|
||||||
|
story(1, 'Alpha', { author: 'Zed', site: 'z.test', status: 'ok', chapter_count: 2,
|
||||||
|
last_changed: '2026-01-01T00:00:00Z', last_checked: '2026-01-03T00:00:00Z' }),
|
||||||
|
story(2, 'Beta', { author: 'Anne', site: 'a.test', status: 'error', chapter_count: 10,
|
||||||
|
last_changed: '2026-01-02T00:00:00Z', last_checked: '2026-01-02T00:00:00Z' }),
|
||||||
|
];
|
||||||
|
await mount(t, list);
|
||||||
|
for (const [label, column, direction, order] of [
|
||||||
|
['Last change', 'last_changed', -1, ['Beta', 'Alpha']],
|
||||||
|
['Last change', 'last_changed', 1, ['Alpha', 'Beta']],
|
||||||
|
['Last check', 'last_checked', -1, ['Alpha', 'Beta']],
|
||||||
|
['Last check', 'last_checked', 1, ['Beta', 'Alpha']],
|
||||||
|
['Title', 'title', 1, ['Alpha', 'Beta']],
|
||||||
|
['Title', 'title', -1, ['Beta', 'Alpha']],
|
||||||
|
['Author', 'author', 1, ['Beta', 'Alpha']],
|
||||||
|
['Author', 'author', -1, ['Alpha', 'Beta']],
|
||||||
|
['Site', 'site', 1, ['Beta', 'Alpha']],
|
||||||
|
['Site', 'site', -1, ['Alpha', 'Beta']],
|
||||||
|
['Status', 'status', 1, ['Beta', 'Alpha']],
|
||||||
|
['Status', 'status', -1, ['Alpha', 'Beta']],
|
||||||
|
['Chapters', 'chapter_count', -1, ['Beta', 'Alpha']],
|
||||||
|
['Chapters', 'chapter_count', 1, ['Alpha', 'Beta']],
|
||||||
|
]) {
|
||||||
|
await click(app, label);
|
||||||
|
assert.deepEqual(JSON.parse(window.localStorage.getItem(SORT_KEY)), { column, direction });
|
||||||
|
await mount(t, list);
|
||||||
|
assert.deepEqual(titles(), order);
|
||||||
|
assert.equal(heading(label).attributes['aria-sort'], direction === 1 ? 'ascending' : 'descending');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const saved of ['not JSON', 'null', '{}', '{"column":"message","direction":-1}',
|
||||||
|
'{"column":"last_changed","direction":0}', '{"column":"last_checked","direction":"-1"}']) {
|
||||||
|
test(`invalid saved sorting falls back to title order: ${saved}`, async (t) => {
|
||||||
|
window.localStorage.setItem(SORT_KEY, saved);
|
||||||
|
await mount(t, [story(2, 'Beta'), story(1, 'Alpha')]);
|
||||||
|
assert.deepEqual(titles(), ['Alpha', 'Beta']);
|
||||||
|
assert.equal(heading('Title').attributes['aria-sort'], 'ascending');
|
||||||
|
await click(app, 'Title');
|
||||||
|
assert.deepEqual(JSON.parse(window.localStorage.getItem(SORT_KEY)), { column: 'title', direction: -1 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sorting works when local storage cannot be read or written', async (t) => {
|
||||||
|
window.localStorage.getItem = () => { throw new Error('Storage blocked'); };
|
||||||
|
window.localStorage.setItem = () => { throw new Error('Storage full'); };
|
||||||
|
await mount(t, [story(2, 'Beta'), story(1, 'Alpha')]);
|
||||||
|
assert.deepEqual(titles(), ['Alpha', 'Beta']);
|
||||||
|
await click(app, 'Title');
|
||||||
|
assert.deepEqual(titles(), ['Beta', 'Alpha']);
|
||||||
|
assert.equal(heading('Title').attributes['aria-sort'], 'descending');
|
||||||
|
assert.equal(toasts.textContent, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
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';
|
||||||
|
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: '<script>Connection failed</script>' }),
|
||||||
|
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', '<script>Connection failed</script>'],
|
||||||
|
[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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user