feat(query): add up-next in series filter operator

Adds the isUpNext operator to seriesStatus allowing books to be filtered by whether they are the next unread book in a series where the previous book is completed.

Closes #321
This commit is contained in:
neon
2026-06-15 04:58:31 +00:00
parent d13fc929e3
commit f525c01a2f
7 changed files with 180 additions and 2 deletions
@@ -32,6 +32,7 @@ const NO_VALUE_OPERATORS: RuleOperator[] = [
'isFinished',
'isLocked',
'isUnlocked',
'isUpNext',
]
const BETWEEN_OPERATORS: RuleOperator[] = ['between']
const COLLECTION_OPERATORS: RuleOperator[] = ['includesAny', 'includesAll', 'excludesAll']
@@ -105,4 +105,33 @@ describe('BookFilterBuilder', () => {
expect(wrapper.find('input').exists()).toBe(false)
})
it('includes Series Status in field options', () => {
const wrapper = mount(BookFilterBuilder, {
props: {
modelValue: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Dune' }] },
},
})
const [fieldSelect] = wrapper.findAll('select')
const optionText = fieldSelect!.findAll('option').map((opt) => opt.text())
expect(optionText).toContain('Series Status')
})
it('offers only is up next and no value input for Series Status', async () => {
const wrapper = mount(BookFilterBuilder, {
props: {
modelValue: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Dune' }] },
},
})
const [fieldSelect] = wrapper.findAll('select')
await fieldSelect!.setValue('seriesStatus')
const operatorSelect = wrapper.findAll('select')[1]
const operatorOptions = operatorSelect!.findAll('option').map((opt) => opt.text())
expect(operatorOptions).toEqual(['is up next'])
expect(wrapper.find('input').exists()).toBe(false)
})
})
@@ -13,6 +13,16 @@ describe('lock status labels', () => {
})
})
describe('series status labels', () => {
it('exposes a Series Status field label', () => {
expect(FIELD_LABELS.seriesStatus).toBe('Series Status')
})
it('exposes an is up next operator label', () => {
expect(OPERATOR_LABELS.isUpNext).toBe('is up next')
})
})
describe('ruleToParts', () => {
it('renders a locked rule with no value', () => {
const rule: Rule = { type: 'rule', field: 'lockStatus', operator: 'isLocked' }
@@ -24,6 +34,11 @@ describe('ruleToParts', () => {
expect(ruleToParts(rule)).toEqual({ field: 'Lock Status', operator: 'is unlocked', value: null })
})
it('renders an up-next rule with no value', () => {
const rule: Rule = { type: 'rule', field: 'seriesStatus', operator: 'isUpNext' }
expect(ruleToParts(rule)).toEqual({ field: 'Series Status', operator: 'is up next', value: null })
})
it('renders withinLast rules with a days suffix', () => {
const rule: Rule = { type: 'rule', field: 'addedAt', operator: 'withinLast', value: 7 }
expect(ruleToParts(rule)).toEqual({ field: 'Added Date', operator: 'within last', value: '7 days' })
@@ -49,6 +49,7 @@ export const FIELD_LABELS: Record<RuleField, string> = {
metadataScore: 'Metadata Score',
cover: 'Cover',
lockStatus: 'Lock Status',
seriesStatus: 'Series Status',
}
export const OPERATOR_LABELS: Record<RuleOperator, string> = {
@@ -78,6 +79,7 @@ export const OPERATOR_LABELS: Record<RuleOperator, string> = {
isFinished: 'is finished',
isLocked: 'is locked',
isUnlocked: 'is unlocked',
isUpNext: 'is up next',
}
const NO_VALUE_OPS: RuleOperator[] = [
@@ -90,6 +92,7 @@ const NO_VALUE_OPS: RuleOperator[] = [
'isFinished',
'isLocked',
'isUnlocked',
'isUpNext',
]
export function ruleToParts(rule: Rule): { field: string; operator: string; value: string | null } {
+7 -2
View File
@@ -15,6 +15,7 @@
* - `format` - resolved via `book_files.format` (primary file)
* - `isbn` - matches both `isbn10` and `isbn13` in `book_metadata`
* - `lockStatus` - derived from `book_metadata.locked_fields` (non-empty array = locked)
* - `seriesStatus` - computed per-user: "up next in series" (next unstarted book whose earlier series entries are all finished)
*/
export type RuleField =
| "title"
@@ -41,7 +42,8 @@ export type RuleField =
| "isbn"
| "metadataScore"
| "cover"
| "lockStatus";
| "lockStatus"
| "seriesStatus";
export type RuleOperator =
| "contains"
@@ -69,7 +71,8 @@ export type RuleOperator =
| "isInProgress"
| "isFinished"
| "isLocked"
| "isUnlocked";
| "isUnlocked"
| "isUpNext";
export const FIELD_OPERATORS: Record<RuleField, RuleOperator[]> = {
title: ["contains", "notContains", "startsWith", "endsWith", "eq", "notEq", "isEmpty", "isNotEmpty"],
@@ -97,6 +100,7 @@ export const FIELD_OPERATORS: Record<RuleField, RuleOperator[]> = {
metadataScore: ["gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
cover: ["isMissing", "isPresent"],
lockStatus: ["isLocked", "isUnlocked"],
seriesStatus: ["isUpNext"],
};
export const RULE_FIELDS = Object.keys(FIELD_OPERATORS) as RuleField[];
@@ -128,6 +132,7 @@ export const RULE_OPERATORS: RuleOperator[] = [
"isFinished",
"isLocked",
"isUnlocked",
"isUpNext",
];
export type Rule = {
@@ -63,6 +63,24 @@ function getRuleSql(where: Record<string, unknown>): Record<string, unknown> {
return outer.clauses[1].clauses[0] as Record<string, unknown>;
}
/**
* Recursively gathers the static SQL text fragments from a (possibly deeply nested)
* mocked clause tree so tests can assert on keywords composed across nested sql`` templates.
*/
function collectSqlText(node: unknown, acc: string[] = []): string[] {
if (!node || typeof node !== 'object') return acc;
const n = node as Record<string, unknown>;
if (n.type === 'sql' && typeof n.text === 'string') acc.push(n.text);
for (const key of ['values', 'clauses'] as const) {
const arr = n[key];
if (Array.isArray(arr)) arr.forEach((child) => collectSqlText(child, acc));
}
for (const key of ['value', 'left', 'right'] as const) {
if (n[key]) collectSqlText(n[key], acc);
}
return acc;
}
const BASE_CTX = { accessibleLibraryIds: [1] as number[] };
const USER_CTX = { accessibleLibraryIds: [1] as number[], userId: 10 };
@@ -88,6 +106,7 @@ function buildValueFor(operator: RuleOperator, field: RuleField): { value?: unkn
case 'isFinished':
case 'isLocked':
case 'isUnlocked':
case 'isUpNext':
return {};
case 'contains':
case 'notContains':
@@ -1169,6 +1188,38 @@ describe('lockStatusRuleToSql', () => {
});
});
describe('seriesStatusRuleToSql', () => {
it('isUpNext builds set membership against the shelf window-function pipeline', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'seriesStatus', operator: 'isUpNext' }) as never, USER_CTX) as any;
const clause = getRuleSql(where);
expect(clause).toMatchObject({ type: 'sql' });
const text = collectSqlText(clause).join(' ').toLowerCase();
expect(text).toContain(' in (');
expect(text).toContain('distinct on');
expect(text).toContain('lag(');
expect(text).toContain('partition by');
expect(text).toContain('previous_is_completed');
expect(text).toContain("'read'");
expect(text).toContain("'skimmed'");
});
it('requires an authenticated user', () => {
const { builder } = makeBuilder();
expect(() => builder.buildWhere(wrapRule({ type: 'rule', field: 'seriesStatus', operator: 'isUpNext' }) as never, BASE_CTX)).toThrow(
'Series status filter requires an authenticated user',
);
});
it('rejects an invalid operator for seriesStatus', () => {
const { builder } = makeBuilder();
expect(() => builder.buildWhere(wrapRule({ type: 'rule', field: 'seriesStatus', operator: 'isFinished' }) as never, USER_CTX)).toThrow(
BadRequestException,
);
});
});
describe('BookQueryBuilder.hasSeriesFilter', () => {
it('returns false for undefined', () => {
expect(BookQueryBuilder.hasSeriesFilter(undefined)).toBe(false);
@@ -9,6 +9,7 @@ import { buildContentFilterClauses } from '../../common/utils/content-filter-sql
import * as schema from '../../db/schema';
import { BookSortBuilder } from './book-sort-builder.service';
import {
audiobookProgress,
authors,
bookAuthors,
bookFiles,
@@ -169,6 +170,8 @@ export class BookQueryBuilder {
return this.coverRuleToSql(operator);
case 'lockStatus':
return this.lockStatusRuleToSql(operator);
case 'seriesStatus':
return this.seriesStatusRuleToSql(operator, userId);
default:
throw new BadRequestException(`Unknown filter field: ${String(field)}`);
}
@@ -502,6 +505,77 @@ export class BookQueryBuilder {
}
}
private seriesStatusRuleToSql(operator: string, userId?: number): SQL {
if (userId === undefined) throw new BadRequestException('Series status filter requires an authenticated user');
switch (operator) {
case 'isUpNext':
return this.upNextInSeriesSql(userId);
default:
throw new BadRequestException(`Invalid operator '${operator}' for seriesStatus field`);
}
}
/**
* Set membership against the same window-function pipeline as the "Up Next in Series" shelf
* (DashboardRepository.findUpNextInSeriesBookIds): per (library, series), the next unstarted book
* whose immediately preceding entry is finished. Implemented as a subquery (not a per-row predicate)
* so results match the shelf exactly. Keep the merged-progress / completion definitions in sync with
* that repository. Library scoping is omitted here because the window partitions by library already;
* the surrounding query restricts to the user's accessible libraries.
*/
private upNextInSeriesSql(userId: number): SQL {
const mergedProgress = sql`coalesce(
case
when rp.updated_at is null then ab.percentage
when ab.updated_at is null then rp.percentage
when rp.updated_at >= ab.updated_at then rp.percentage
else ab.percentage
end,
rp.percentage,
ab.percentage,
0
)`;
const isCompleted = sql`(ubs.status in ('read', 'skimmed') or ${mergedProgress} >= 100)`;
return sql`${books.id} in (
with scoped as (
select
b.id as id,
b.library_id as library_id,
m.series_id as series_id,
m.series_index as series_index,
b.added_at as added_at,
${mergedProgress} as current_progress,
case when ${isCompleted} then true else false end as is_completed
from ${books} b
inner join ${bookMetadata} m on m.book_id = b.id
left join ${bookFiles} bf on bf.id = b.primary_file_id
left join ${readingProgress} rp on rp.book_file_id = bf.id and rp.user_id = ${userId}
left join ${audiobookProgress} ab on ab.book_id = b.id and ab.user_id = ${userId}
left join ${userBookStatus} ubs on ubs.book_id = b.id and ubs.user_id = ${userId}
where b.status = 'present' and m.series_id is not null and m.series_index is not null
),
ordered as (
select
s.id,
s.library_id,
s.series_id,
s.series_index,
s.added_at,
s.is_completed,
s.current_progress,
lag(s.is_completed) over (
partition by s.library_id, s.series_id
order by s.series_index asc, s.added_at asc, s.id asc
) as previous_is_completed
from scoped s
)
select distinct on (o.library_id, o.series_id) o.id
from ordered o
where o.previous_is_completed = true and o.is_completed = false and o.current_progress = 0
order by o.library_id, o.series_id, o.series_index asc, o.added_at asc, o.id asc
)`;
}
private readProgressRuleToSql(operator: string, userId?: number): SQL {
if (userId === undefined) throw new BadRequestException('Reading progress filter requires an authenticated user');
const existsReadingProgress = (whereClause: SQL) => {