forked from akai/readest
Sort the library by reading progress (current/total pages). Books that have never been opened read 0% and sort to the unread end; groups sort by their most-progressed book. Direction reuses the existing ascending/descending toggle, so "most read first" is Descending. Adds the "Progress Read" entry to the Sort by menu and translates it across all locales. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -195,6 +195,21 @@ describe('getBookSortValue', () => {
|
||||
const book = createMockBook({ updatedAt: 99999 });
|
||||
expect(getBookSortValue(book, 'unknown' as LibrarySortByType)).toBe(99999);
|
||||
});
|
||||
|
||||
it('should return read ratio for Progress sort', () => {
|
||||
const book = createMockBook({ progress: [50, 100] });
|
||||
expect(getBookSortValue(book, LibrarySortByType.Progress)).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should return 0 for Progress sort when book has no progress', () => {
|
||||
const book = createMockBook({});
|
||||
expect(getBookSortValue(book, LibrarySortByType.Progress)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 for Progress sort when total pages is 0', () => {
|
||||
const book = createMockBook({ progress: [0, 0] });
|
||||
expect(getBookSortValue(book, LibrarySortByType.Progress)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareSortValues', () => {
|
||||
@@ -310,4 +325,21 @@ describe('createBookSorter - additional cases', () => {
|
||||
expect(sorted[0]!.title).toBe('Older');
|
||||
expect(sorted[1]!.title).toBe('Newer');
|
||||
});
|
||||
|
||||
it('should sort by reading progress ascending (unread books first)', () => {
|
||||
const books = [
|
||||
createMockBook({ title: 'Almost done', progress: [90, 100] }),
|
||||
createMockBook({ title: 'Not started' }),
|
||||
createMockBook({ title: 'Just begun', progress: [10, 100] }),
|
||||
createMockBook({ title: 'Halfway', progress: [1, 2] }),
|
||||
];
|
||||
const sorter = createBookSorter(LibrarySortByType.Progress, 'en');
|
||||
const sorted = [...books].sort(sorter);
|
||||
expect(sorted.map((b) => b.title)).toEqual([
|
||||
'Not started',
|
||||
'Just begun',
|
||||
'Halfway',
|
||||
'Almost done',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -544,6 +544,18 @@ describe('getGroupSortValue', () => {
|
||||
expect(getGroupSortValue(group, LibrarySortByType.Series)).toBe('My Series');
|
||||
});
|
||||
|
||||
it('should return max read ratio for progress sort', () => {
|
||||
const group = createMockGroup({
|
||||
books: [
|
||||
createMockBook({ progress: [10, 100] }),
|
||||
createMockBook({ progress: [80, 100] }),
|
||||
createMockBook({}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(getGroupSortValue(group, LibrarySortByType.Progress)).toBe(0.8);
|
||||
});
|
||||
|
||||
it('should handle empty groups gracefully', () => {
|
||||
const group = createMockGroup({ books: [] });
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
{ label: _('Date Read'), value: LibrarySortByType.Updated },
|
||||
{ label: _('Date Added'), value: LibrarySortByType.Created },
|
||||
{ label: _('Date Published'), value: LibrarySortByType.Published },
|
||||
{ label: _('Progress Read'), value: LibrarySortByType.Progress },
|
||||
];
|
||||
|
||||
const sortBy2Options: { label: string; value: LibrarySecondarySortByType }[] = [
|
||||
|
||||
@@ -178,6 +178,17 @@ export const createBookFilter = (queryTerm: string | null) => (item: Book) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fraction of the book that has been read, in [0, 1]. `progress` is a 1-based
|
||||
* `[current, total]` page pair; books that have never been opened have no
|
||||
* progress and read 0 (they sort to the unread end).
|
||||
*/
|
||||
const getBookReadRatio = (book: Book): number => {
|
||||
const [current, total] = book.progress ?? [];
|
||||
if (!current || !total || total <= 0) return 0;
|
||||
return current / total;
|
||||
};
|
||||
|
||||
const compareBookByKey = (a: Book, b: Book, sortBy: string, uiLanguage: string): number => {
|
||||
switch (sortBy) {
|
||||
case LibrarySortByType.Title: {
|
||||
@@ -196,6 +207,8 @@ const compareBookByKey = (a: Book, b: Book, sortBy: string, uiLanguage: string):
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
case LibrarySortByType.Format:
|
||||
return a.format.localeCompare(b.format, uiLanguage || navigator.language);
|
||||
case LibrarySortByType.Progress:
|
||||
return getBookReadRatio(a) - getBookReadRatio(b);
|
||||
case LibrarySortByType.Series: {
|
||||
// Group by series name first so books of the same series stay consecutive,
|
||||
// then order within a series by index. Comparing index alone would interleave
|
||||
@@ -496,6 +509,9 @@ export const getBookSortValue = (book: Book, sortBy: LibrarySortByType): number
|
||||
case LibrarySortByType.Format:
|
||||
return book.format;
|
||||
|
||||
case LibrarySortByType.Progress:
|
||||
return getBookReadRatio(book);
|
||||
|
||||
case LibrarySortByType.Published: {
|
||||
const published = book.metadata?.published;
|
||||
if (!published) return 0;
|
||||
@@ -555,6 +571,10 @@ export const getGroupSortValue = (
|
||||
// Return the most recent createdAt
|
||||
return Math.max(...books.map((b) => b.createdAt));
|
||||
|
||||
case LibrarySortByType.Progress:
|
||||
// Return the most-progressed book's read ratio
|
||||
return Math.max(...books.map((b) => getBookReadRatio(b)));
|
||||
|
||||
case LibrarySortByType.Published: {
|
||||
// Return the most recent published date
|
||||
const publishedDates = books
|
||||
|
||||
@@ -18,6 +18,7 @@ export const LibrarySortByType = {
|
||||
Size: 'size',
|
||||
Format: 'format',
|
||||
Published: 'published',
|
||||
Progress: 'progress',
|
||||
} as const;
|
||||
|
||||
export type LibrarySortByType = (typeof LibrarySortByType)[keyof typeof LibrarySortByType];
|
||||
|
||||
Reference in New Issue
Block a user