diff --git a/bindery/static/js/stories.js b/bindery/static/js/stories.js index 0885565..0a7a02c 100644 --- a/bindery/static/js/stories.js +++ b/bindery/static/js/stories.js @@ -3,6 +3,7 @@ import { jobPanel, refreshJobs, watchJobs } from './jobs.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' }, @@ -219,6 +220,15 @@ export function storiesPage(main, _params, config) { 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 jobs = jobPanel({ hours: config.main_jobs_hours, count: config.main_jobs_count }); @@ -260,6 +270,11 @@ export function storiesPage(main, _params, config) { 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 }); diff --git a/tests/js/story-list.test.mjs b/tests/js/story-list.test.mjs index b2e1a10..931138b 100644 --- a/tests/js/story-list.test.mjs +++ b/tests/js/story-list.test.mjs @@ -1,8 +1,17 @@ import assert from 'node:assert/strict'; -import { test } from 'node:test'; +import { beforeEach, test } from 'node:test'; import { click, field, find, input, install, nodes, server, settle } from './flow-helpers.mjs'; -const { app } = install(); +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'); @@ -112,6 +121,61 @@ for (const [label, key] of [['Author', 'author'], ['Site', 'site'], ['Status', ' }); } +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' }),