Continue row: a run of more than N chapters in order also marks a reread

A done chapter after the head now counts as left again when it was finished
more than rereadDays before the head (default 7) or before a run of more
than rereadChapters chapters read in order up to the head (default 5).
Both are user settings under Library page; 0 turns a rule off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MaashkinoozheandClaude Opus 5.5 committed 2026-11-01 11:47:45 +01:00
1 parent 9b529d7709
commit c760141636
5 files changed
+49 -13

No files matched your search

+1 -1
View File
@@ -148,7 +148,7 @@ completeness.
- **Flow page gap:** Settings → Reading lets each user keep the normal separator between Flow pages or remove it for continuous webtoon artwork and text that crosses image edges.
- **Page tone:** the reader's **Tone** button cycles Original → Inverted → Sepia → Dim → Extra dim in Flow, Page and Book. It changes only the artwork, never the controls or site theme. The choice is saved per user; it can also be selected in Settings → User → Reading.
- **Active Reading Clock:** opt in under Settings → User → Reading time & privacy. No scrolling-speed limit; a visit counts at 60% viewed-page coverage and saves on exit. Pauses in the background or after 30 seconds without reader activity. Tap the clock for this visit's time, coverage, qualification and loading wait.
- **Continue where you left off:** the first line of the library content (below the search, filters and tags) is one row of the five manga you read most recently that still have something left after the last chapter you read (same tiles as the grid; they stay in the grid too). Left means an unfinished last chapter, an unread later chapter, or — when you are rereading — a later chapter last read more than a week before your current position. A manga you have caught up on drops out until a new chapter arrives. It hides while a search is typed, a status filter is set, or the library is sorted by *Recently read*; tags and collection grouping do not affect it. Turn it off under Settings → User → Reading → *Library page*.
- **Continue where you left off:** the first line of the library content (below the search, filters and tags) is one row of the five manga you read most recently that still have something left after the last chapter you read (same tiles as the grid; they stay in the grid too). Left means an unfinished last chapter, an unread later chapter, or — when you are rereading — a later chapter you finished in an earlier pass: more than *N days* before your current position (default 7), or before a run of more than *M chapters* you have since read in order up to it (default 5). Both are set under Settings → User → Reading → *Library page*; 0 turns a rule off. A manga you have caught up on drops out until a new chapter arrives. It hides while a search is typed, a status filter is set, or the library is sorted by *Recently read*; tags and collection grouping do not affect it. Turn it off under Settings → User → Reading → *Library page*.
- **Your Reading Story:** private active-time summaries appear on the library and manga pages when history exists. Chapter rows show the last timed read (60%-coverage visit), rereads remain visible as lifetime time, and Settings lets each user hide, export, or delete their own history. No leaderboards or speed ranking are used.
- **Tap / click zones:**
- Tapping **beside the pages** always shows or hides the controls.
+3 -1
View File
@@ -129,6 +129,8 @@ This keeps ids human-readable and derivable without a database. The price: renam
"readingClock": false, // opt in to credible per-chapter active-time tracking
"readingStory": true, // show private retrospective summaries when history exists
"continueRow": true, // library: "Continue where you left off" row at the top
"rereadDays": 7, // continue row: a later chapter finished this long before the head is left again (0 = off)
"rereadChapters": 5, // ... or one finished before a run of more than this many in-order chapters (0 = off)
"zoom": { "flow": 1, "page": 1.5, "book": 1, "strip": 0.8 }, // page zoom per mode
"webhook": "",
"readerPerSession": true // reader settings per login session (section 6.7)
@@ -201,7 +203,7 @@ All requests and responses are JSON. POSTs require `Content-Type: application/js
| `GET /api/reader-sessions` | user | `{ enabled, current, latestFrom, latest, sessions: [{ id, device, created, seen, current, locked, reader }] }` |
| `POST /api/reader-sessions` `{id, action}` | user | `follow`: the session drops its own reader settings and follows the latest; `latest`: its settings become the latest |
| `POST /api/password` `{old, new}` | user | Change password |
| `GET /api/library` | user | Manga list with covers, counts, the user's read count, unread notifications, last read, `left` (chapters after the newest saved position that are unfinished, unread, or last read over 7 days before it, i.e. a reread; 0 = caught up), and optional personal Reading Story aggregates |
| `GET /api/library` | user | Manga list with covers, counts, the user's read count, unread notifications, last read, `left` (chapters after the newest saved position that are unfinished, unread, or finished in an earlier pass — over `rereadDays` before it, or before a run of more than `rereadChapters` chapters read in order up to it; 0 = caught up), and optional personal Reading Story aggregates |
| `GET /api/manga?m=` | user | Manga details, books, chapters with ranked versions, progress, optional chapter-time summaries, and `continue` target |
| `GET /api/chapter?m=&c=[&v=]` | user | Pages (`u, w, h, t, r`), direction, versions, prev/next, progress, `latest` (as in `/api/progress/latest`), optional Active Reading Clock summary, plus all books and chapters of the manga (with the user's read state) for the chapter picker. Also marks that chapter's notifications read |
| `POST /api/progress` `{m, c, v, p, n, done, shift[, tab, seen]}` | user | Save the reading position. With `seen`, a save is refused (`{ok:false, stale:true, latest}`) when another tab saved a newer position of this manga since; otherwise `{ok:true, t}` |
+7 -1
View File
@@ -74,6 +74,9 @@
const check = (key, text, hint) => h('label', { class: 'check' },
h('input', { type: 'checkbox', checked: !!S[key], onchange: e => save({ [key]: e.target.checked }) }),
h('span', {}, text, hint ? h('div', { class: 'muted small' }, hint) : null));
const number = (key, text, max) => h('label', { class: 'field' }, h('span', {}, text),
h('input', { type: 'number', min: 0, max, step: 1, value: S[key], style: 'width:6em',
onchange: e => { const n = Math.round(+e.target.value); if (n >= 0 && n <= max) save({ [key]: n }); else e.target.value = S[key]; } }));
const startMode = h('select', { onchange: e => save({ startMode: e.target.value }) },
h('option', { value: 'last' }, 'The mode I used last'),
@@ -145,7 +148,10 @@
section('user/reading').append(h('section', { class: 'card' },
h('h2', {}, 'Library page'),
check('continueRow', 'Show "Continue where you left off" at the top',
'One row with the five manga you read most recently and have not finished. It hides itself while you search, filter by status, or sort by "Recently read".')));
'One row with the five manga you read most recently that still have chapters left after the last one you read. It hides itself while you search, filter by status, or sort by "Recently read".'),
h('p', { class: 'muted small' }, 'Rereading: later chapters you finished in an earlier pass count as left again when either rule applies. 0 turns a rule off.'),
number('rereadDays', 'Finished more than this many days before my last read', 3650),
number('rereadChapters', 'Or I have since read more than this many chapters in a row', 1000)));
section('user/reading').append(h('section', { class: 'card' },
h('h2', {}, 'Chapter list dates'),
+16 -6
View File
@@ -120,7 +120,7 @@ function checkPw(pw, stored) {
}
const DUMMY_HASH = hashPw('x');
const DEFAULTS = { startMode: 'last', lastMode: 'page', adapt: true, skipExtras: false, skipCovers: false, flowPageGap: true, pageTone: 'original', readingClock: false, readingStory: true,
continueRow: true, readDate: 'hour', releaseDate: true, discoveryEnabled: false, discoveryReadingActivity: false, discoveryMature: false, zoom: {}, webhook: '', readerPerSession: true };
continueRow: true, rereadDays: 7, rereadChapters: 5, readDate: 'hour', releaseDate: true, discoveryEnabled: false, discoveryReadingActivity: false, discoveryMature: false, zoom: {}, webhook: '', readerPerSession: true };
const userSettingsOf = u => ({ ...DEFAULTS, ...u.settings, zoom: { ...(u.settings && u.settings.zoom) } });
// With a login session, its locked reader settings (reader-sessions.js) win over the user's latest set.
const settingsOf = (u, sess) => readerSessions.effective(userSettingsOf(u), sess);
@@ -465,15 +465,23 @@ function continueOf(m, prog, s) {
const next = m.chapters.slice(m.chapters.indexOf(last.ch) + 1).find(ok);
return next ? { c: next.id, at, text: 'Read next · ' + next.label } : { c: last.ch.id, v: last.p.v, at, text: 'Read again · ' + last.ch.label };
}
// A chapter after the head counts as read in this pass only if it is done and was saved within this
// gap before the head; older ones belong to an earlier pass, so the head is a reread.
const REREAD_GAP_MS = 7 * 24 * 3600 * 1000;
// Chapters still ahead of the head: never finished, or last read in an earlier pass. 0 = caught up.
// A done chapter after the head belongs to an earlier pass (the head is a reread) when it was saved
// more than `rereadDays` before the head, or before a run of more than `rereadChapters` chapters read
// in order up to the head. 0 turns either rule off.
function leftOf(m, prog, s) {
const last = lastOf(m, prog);
if (!last) return 0;
const after = m.chapters.slice(m.chapters.indexOf(last.ch) + 1).filter(c => !(s.skipExtras && c.extra));
return after.filter(c => { const p = prog[c.id]; return !p || !p.done || last.p.t - p.t > REREAD_GAP_MS; }).length
const chapters = m.chapters.filter(c => c === last.ch || !(s.skipExtras && c.extra)), at = chapters.indexOf(last.ch);
let runStart = last.p.t, run = 1;
for (let i = at - 1; i >= 0; i--) {
const p = prog[chapters[i].id];
if (!p || !p.done || p.t > runStart) break;
runStart = p.t; run++;
}
const gap = s.rereadDays > 0 ? s.rereadDays * 24 * 3600 * 1000 : Infinity;
const passStart = s.rereadChapters > 0 && run > s.rereadChapters ? runStart : -Infinity;
return chapters.slice(at + 1).filter(c => { const p = prog[c.id]; return !p || !p.done || last.p.t - p.t > gap || p.t < passStart; }).length
+ (last.p.done ? 0 : 1);
}
// What a reader tab compares itself with (stale reading sessions, docs/architecture.md).
@@ -612,6 +620,8 @@ const routes = {
if (['flow', 'page', 'book'].includes(b.lastMode)) s.lastMode = b.lastMode;
if (['original', 'inverted', 'sepia', 'dim', 'extra-dim'].includes(b.pageTone)) s.pageTone = b.pageTone;
if (['off', 'date', 'hour'].includes(b.readDate)) s.readDate = b.readDate;
if (Number.isInteger(b.rereadDays) && b.rereadDays >= 0 && b.rereadDays <= 3650) s.rereadDays = b.rereadDays;
if (Number.isInteger(b.rereadChapters) && b.rereadChapters >= 0 && b.rereadChapters <= 1000) s.rereadChapters = b.rereadChapters;
for (const k of ['adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'readingClock', 'readingStory', 'releaseDate', 'continueRow',
'discoveryEnabled', 'discoveryReadingActivity', 'discoveryMature', 'readerPerSession']) if (typeof b[k] === 'boolean') s[k] = b[k];
if (b.zoom && typeof b.zoom === 'object') {
+22 -4
View File
@@ -39,9 +39,9 @@ test('the continue row counts chapters left after the head, rereads included', {
let server;
try {
const library = path.join(root, 'library'), data = path.join(root, 'data');
for (const title of ['Moon Garden', 'Sun Field'])
for (const ch of ['c001 One', 'c002 Two', 'c003 Three', 'c004 Four']) {
const dir = path.join(library, title, 'v01', ch);
for (const [title, count] of [['Moon Garden', 4], ['Sun Field', 4], ['Star Path', 8]])
for (let n = 1; n <= count; n++) {
const dir = path.join(library, title, 'v01', `c00${n}`);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '001.png'), new Canvas(60, 90, [40, 60, 120]).png());
}
@@ -63,7 +63,25 @@ test('the continue row counts chapters left after the head, rereads included', {
// First read stopped after chapter 2: two chapters left. Finished: nothing left.
for (const ch of moon.slice(0, 2)) await done('Moon Garden', ch);
for (const ch of sun) await done('Sun Field', ch);
assert.deepEqual(await left(), { 'Moon Garden': 2, 'Sun Field': 0 });
assert.deepEqual(await left(), { 'Moon Garden': 2, 'Sun Field': 0, 'Star Path': 0 });
// Read all 8 chapters of Star Path, then reread 1-6 right away: more than 5 in a row is a reread
// even within the time limit; with a longer run setting, or the run rule off, it is not.
const star = await ids('Star Path');
for (const ch of star) await done('Star Path', ch);
await new Promise(resolve => setTimeout(resolve, 5));
for (const ch of star.slice(0, 5)) await done('Star Path', ch);
assert.equal((await left())['Star Path'], 0, 'five in a row is not yet a reread');
await done('Star Path', star[5]);
assert.equal((await left())['Star Path'], 2);
await post(alice, '/api/settings', { rereadChapters: 6 });
assert.equal((await left())['Star Path'], 0);
await post(alice, '/api/settings', { rereadChapters: 0 });
assert.equal((await left())['Star Path'], 0);
await post(alice, '/api/settings', { rereadChapters: 5, rereadDays: -1 });
const settings = (await get(alice, '/api/me')).settings;
assert.equal(settings.rereadDays, 7, 'invalid values are ignored');
assert.equal((await left())['Star Path'], 2);
// Unfinished head: the rest of that chapter is still left.
await post(alice, '/api/progress', { m: 'Moon Garden', ...moon[2], p: 0, n: 1 });