Chapter dates: read date setting and tiered release dates (BL-0047)
- Chapter rows show when you first finished a chapter (setting: date + hour, date, off) from a new durable progress doneAt; older reads fall back to the reading clock's earliest remembered completion. - chapter.json keeps added (arrival in this library, now kept on replace) plus x-dates.source (source upload, bulk imports flagged), x-dates.release (official, for a later metadata job) and x-dates.ecosystem (first entry into any Manga Circus library; carried over peering, earliest wins, clamped). - Rows show one release date by tier: official > source upload > ecosystem > library, with all known dates in the tooltip (setting to hide). - Fixes: WeebCentral dates came from the chapter page, which has none (every chapter got the download time); they now come from the full chapter list. MangaDex no longer writes a group's publishAt into added. Peering sends dates. - tools/backfill-chapter-dates.js migrates an existing library (dry run default). - Coverage dry run on the server: docs/chapter-dates-coverage-2026-10-24.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b2bb34174c
commit
e0dacd81c6
21 files changed
+470
-32
No files matched your search
@@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { batchFlags, publishedDates, chapterDates, peerDates, fromPeer } = require('../chapter-dates');
|
||||
const { earliestCompletion } = require('../reading-analytics');
|
||||
|
||||
test('bulk imports are flagged, weekly uploads are not', () => {
|
||||
const flags = batchFlags([
|
||||
'2024-09-07T10:00:00Z', '2024-09-07T10:00:03Z', '2024-09-07T10:04:00Z', // one import
|
||||
'2026-10-11T15:05:58Z', '2026-10-18T15:06:05Z', // weekly releases
|
||||
'2026-10-20T09:00:00Z', '2026-10-20T09:01:00Z', // a double release stays a release
|
||||
undefined,
|
||||
]);
|
||||
assert.deepEqual(flags, [true, true, true, false, false, false, false, null]);
|
||||
});
|
||||
|
||||
test('publishing keeps the first arrival and the earliest ecosystem date', () => {
|
||||
const now = Date.parse('2026-10-24T12:00:00Z');
|
||||
const fresh = publishedDates({}, { source: { at: '2026-10-18T15:06:05Z', name: 'WeebCentral', batch: false } }, now);
|
||||
assert.equal(fresh.added, '2026-10-24T12:00:00.000Z');
|
||||
assert.deepEqual(fresh['x-dates'].ecosystem, { at: '2026-10-24T12:00:00.000Z', via: 'this server' });
|
||||
assert.equal(fresh['x-dates'].source.name, 'WeebCentral');
|
||||
|
||||
// Replacing a version later: "added" and the ecosystem date survive.
|
||||
const replaced = publishedDates(fresh, {}, now + 864e5);
|
||||
assert.equal(replaced.added, fresh.added);
|
||||
assert.deepEqual(replaced['x-dates'].ecosystem, fresh['x-dates'].ecosystem);
|
||||
assert.equal(replaced['x-dates'].source.at, '2026-10-18T15:06:05.000Z');
|
||||
|
||||
// A peer that had it earlier lowers the date; a later claim never raises it.
|
||||
const earlier = publishedDates(fresh, { ecosystem: { at: '2026-10-19T08:00:00Z', via: 'peer bob' } }, now);
|
||||
assert.deepEqual(earlier['x-dates'].ecosystem, { at: '2026-10-19T08:00:00.000Z', via: 'peer bob' });
|
||||
const later = publishedDates(earlier, { ecosystem: { at: '2026-10-23T00:00:00Z', via: 'peer eve' } }, now);
|
||||
assert.equal(later['x-dates'].ecosystem.via, 'peer bob');
|
||||
});
|
||||
|
||||
test('peer ecosystem claims cannot predate the source upload or lie in the future', () => {
|
||||
const now = Date.parse('2026-10-24T12:00:00Z');
|
||||
const source = { at: '2026-10-18T15:06:05Z', name: 'WeebCentral', batch: false };
|
||||
const early = publishedDates({}, { source, ecosystem: { at: '2001-01-01T00:00:00Z', via: 'peer x' } }, now);
|
||||
assert.equal(early['x-dates'].ecosystem.at, '2026-10-18T15:06:05.000Z');
|
||||
const future = publishedDates({}, { ecosystem: { at: '2099-01-01T00:00:00Z', via: 'peer x' } }, now);
|
||||
assert.equal(future['x-dates'].ecosystem.at, '2026-10-24T12:00:00.000Z');
|
||||
});
|
||||
|
||||
test('release date tiers: official, source upload, ecosystem, library', () => {
|
||||
const added = Date.parse('2026-10-17T15:04:03Z');
|
||||
const version = dates => ({ added, meta: { 'x-dates': dates } });
|
||||
const ecosystem = { at: '2026-10-10T00:00:00Z', via: 'peer bob' };
|
||||
assert.equal(chapterDates([version({})]).tier, 'library');
|
||||
assert.equal(chapterDates([version({ ecosystem })]).release.at, '2026-10-10T00:00:00.000Z');
|
||||
const batch = chapterDates([version({ ecosystem, source: { at: '2024-09-07T10:00:00Z', name: 'WeebCentral', batch: true } })]);
|
||||
assert.equal(batch.tier, 'ecosystem', 'a bulk upload is no release date');
|
||||
assert.equal(batch.known.batch.at, '2024-09-07T10:00:00.000Z');
|
||||
const source = chapterDates([version({ ecosystem }), version({ source: { at: '2026-10-04T15:06:05Z', name: 'MangaDex', batch: false } })]);
|
||||
assert.equal(source.tier, 'source');
|
||||
assert.equal(source.known.ecosystem.at, '2026-10-10T00:00:00.000Z', 'earliest across versions');
|
||||
const official = chapterDates([version({ release: { at: '2023-09-17', precision: 'day', source: 'MangaUpdates · Viz' }, ecosystem })]);
|
||||
assert.deepEqual(official.release, { at: '2023-09-17', precision: 'day', name: 'MangaUpdates · Viz' });
|
||||
assert.equal(official.tier, 'official');
|
||||
});
|
||||
|
||||
test('peers exchange cleaned dates and the receiver records the peer', () => {
|
||||
const meta = { added: '2026-10-17T15:04:03Z', secret: 'x',
|
||||
'x-dates': { source: { at: '2026-10-18T15:06:05Z', name: 'WeebCentral', batch: false, extra: 'drop' } } };
|
||||
const sent = peerDates(meta, 0);
|
||||
assert.deepEqual(sent, { ecosystem: '2026-10-17T15:04:03.000Z', source: { at: '2026-10-18T15:06:05.000Z', name: 'WeebCentral', batch: false } });
|
||||
const received = fromPeer(sent, 'bob');
|
||||
assert.deepEqual(received.ecosystem, { at: '2026-10-17T15:04:03.000Z', via: 'peer bob' });
|
||||
assert.deepEqual(fromPeer('nonsense', 'bob'), {});
|
||||
assert.deepEqual(fromPeer({ ecosystem: 'not a date' }, 'bob'), {});
|
||||
});
|
||||
|
||||
test('legacy reads get the earliest completion the reading clock remembers', () => {
|
||||
assert.equal(earliestCompletion(null), null);
|
||||
const record = { completedPasses: 3, currentPass: { completedAt: 5000 },
|
||||
recentSessions: [{ pass: { completedAt: 3000 } }, { pass: { completedAt: null } }, { pass: { completedAt: 5000 } }] };
|
||||
assert.deepEqual(earliestCompletion(record), { at: 3000, approx: true });
|
||||
assert.deepEqual(earliestCompletion({ ...record, completedPasses: 2 }), { at: 3000, approx: false });
|
||||
});
|
||||
Reference in new issue
Block a user