feat(smart-scope): filter by community review count

Allow provider-aware review count criteria so rating thresholds can be qualified by community review volume.

Closes #1041
This commit is contained in:
neon
2026-08-31 04:54:00 +00:00
parent 79b02e5b35
commit 0f5429131a
15 changed files with 16998 additions and 24 deletions
@@ -11,6 +11,7 @@ import {
isCustomRuleField,
parseCustomRuleFieldId,
type CommunityRatingProvider,
type CommunityRatingRuleField,
type CustomMetadataFieldType,
type CustomRuleField,
type GroupRule,
@@ -44,7 +45,16 @@ const emit = defineEmits<{
const MAX_DEPTH = 5
const BYTES_PER_MIB = 1024 ** 2
const NUMERIC_FIELDS: RuleField[] = ['seriesIndex', 'publishedYear', 'pageCount', 'fileSize', 'rating', 'communityRating', 'metadataScore']
const NUMERIC_FIELDS: RuleField[] = [
'seriesIndex',
'publishedYear',
'pageCount',
'fileSize',
'rating',
'communityRating',
'communityRatingCount',
'metadataScore',
]
const DATE_FIELDS: RuleField[] = ['publishedDate', 'addedAt', 'startedAt', 'finishedAt']
const NO_VALUE_OPERATORS: RuleOperator[] = [
'isEmpty',
@@ -135,6 +145,15 @@ function isNumericField(field: RuleField): boolean {
return NUMERIC_FIELDS.includes(field) || customFieldType(field) === 'number'
}
function isCommunityRatingField(field: RuleField): field is CommunityRatingRuleField {
return field === 'communityRating' || field === 'communityRatingCount'
}
function providerForRule(rule: Rule): CommunityRatingProvider {
if (rule.field === 'communityRating' || rule.field === 'communityRatingCount') return rule.provider ?? 'any'
return 'any'
}
function isDateField(field: RuleField): boolean {
return DATE_FIELDS.includes(field) || customFieldType(field) === 'date'
}
@@ -173,7 +192,7 @@ function toEditableRule(r: Rule): EditableRule {
return {
field: r.field,
operator: r.operator,
provider: r.field === 'communityRating' ? (r.provider ?? 'any') : 'any',
provider: providerForRule(r),
value: usesChips ? '' : value,
valueChips: usesChips ? (r.value as string[]) : [],
valueTo,
@@ -257,7 +276,7 @@ function emitUpdate() {
: n.rule.valueTo
: undefined,
}
return n.rule.field === 'communityRating' ? ({ ...rule, provider: n.rule.provider } as Rule) : (rule as Rule)
return isCommunityRatingField(n.rule.field) ? ({ ...rule, provider: n.rule.provider } as Rule) : (rule as Rule)
})
const isSubGroup = (props.depth ?? 0) > 0
if (!isSubGroup && rules.length === 0 && !props.preserveIncompleteRoot) {
@@ -393,7 +412,7 @@ function valueInputType(field: RuleField, operator: RuleOperator): string {
function numericInputMin(field: RuleField): string | undefined {
if (field === 'fileSize') return '0'
if (field === 'communityRating') return '0'
if (isCommunityRatingField(field)) return '0'
return undefined
}
@@ -406,6 +425,7 @@ function numericInputMax(field: RuleField): string | undefined {
function numericInputStep(field: RuleField): string | undefined {
if (field === 'fileSize') return '0.1'
if (field === 'communityRating') return '0.1'
if (field === 'communityRatingCount') return '1'
return undefined
}
@@ -465,7 +485,7 @@ function showValueToInput(operator: RuleOperator): boolean {
</select>
<div
v-if="node.rule.field === 'communityRating'"
v-if="isCommunityRatingField(node.rule.field)"
class="h-9 flex items-center gap-2 rounded-md border border-input bg-background px-2 shrink-0"
>
<img
@@ -234,6 +234,46 @@ describe('BookFilterBuilder', () => {
expect((wrapper.get('select[aria-label="Community rating provider"]').element as HTMLSelectElement).value).toBe('any')
})
it('serializes a provider-aware community review count rule', 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('communityRatingCount')
await wrapper.findAll('select')[1]!.setValue('gte')
await wrapper.get('select[aria-label="Community rating provider"]').setValue('hardcover')
const input = wrapper.get('input[type="number"]')
expect(input.attributes('min')).toBe('0')
expect(input.attributes('max')).toBeUndefined()
expect(input.attributes('step')).toBe('1')
await input.setValue('1000')
expect(lastUpdate(wrapper)).toEqual({
type: 'group',
join: 'AND',
rules: [{ type: 'rule', field: 'communityRatingCount', operator: 'gte', value: 1000, valueTo: undefined, provider: 'hardcover' }],
})
})
it('hydrates a saved community review count provider', () => {
const wrapper = mount(BookFilterBuilder, {
props: {
modelValue: {
type: 'group',
join: 'AND',
rules: [{ type: 'rule', field: 'communityRatingCount', operator: 'lt', value: 25, provider: 'goodreads' }],
},
},
})
expect((wrapper.get('select[aria-label="Community rating provider"]').element as HTMLSelectElement).value).toBe('goodreads')
expect((wrapper.get('input[type="number"]').element as HTMLInputElement).value).toBe('25')
})
})
describe('BookFilterBuilder custom metadata fields', () => {
@@ -1,6 +1,6 @@
import type { Rule, StaticRuleField, TableViewType } from '@bookorbit/types'
type QuickFilterField = Exclude<StaticRuleField, 'communityRating'>
type QuickFilterField = Exclude<StaticRuleField, 'communityRating' | 'communityRatingCount'>
const TEXT_FIELD_MAP: Partial<Record<string, QuickFilterField>> = {
title: 'title',
@@ -137,6 +137,10 @@ describe('community rating labels', () => {
it('exposes a Community Rating field label', () => {
expect(fieldLabel('communityRating')).toBe('Community Rating')
})
it('exposes a Community Review Count field label', () => {
expect(fieldLabel('communityRatingCount')).toBe('Community Review Count')
})
})
describe('ruleToParts', () => {
@@ -189,4 +193,9 @@ describe('ruleToParts', () => {
const rule: Rule = { type: 'rule', field: 'communityRating', provider: 'any', operator: 'gte', value: 4.5 }
expect(ruleToParts(rule)).toEqual({ field: 'Community Rating (Any provider)', operator: 'at least', value: '4.5' })
})
it('renders provider context for community review count rules', () => {
const rule: Rule = { type: 'rule', field: 'communityRatingCount', provider: 'hardcover', operator: 'gte', value: 1000 }
expect(ruleToParts(rule)).toEqual({ field: 'Community Review Count (Hardcover)', operator: 'at least', value: '1000' })
})
})
@@ -44,14 +44,17 @@ const NO_VALUE_OPS: RuleOperator[] = [
]
function communityRatingProviderLabel(rule: Rule): string {
if (rule.field !== 'communityRating') return ''
if (rule.field !== 'communityRating' && rule.field !== 'communityRatingCount') return ''
const provider = rule.provider ?? 'any'
if (provider === 'any') return t('book.filter.anyProvider')
return PROVIDER_SHORT_LABELS[provider] ?? provider
}
export function ruleToParts(rule: Rule): { field: string; operator: string; value: string | null } {
const field = rule.field === 'communityRating' ? `${fieldLabel(rule.field)} (${communityRatingProviderLabel(rule)})` : fieldLabel(rule.field)
const field =
rule.field === 'communityRating' || rule.field === 'communityRatingCount'
? `${fieldLabel(rule.field)} (${communityRatingProviderLabel(rule)})`
: fieldLabel(rule.field)
const operator = operatorLabel(rule.operator)
if (NO_VALUE_OPS.includes(rule.operator)) return { field, operator, value: null }
if (rule.operator === 'withinLast') return { field, operator, value: `${rule.value} ${t('book.filter.unit.days')}` }
+1
View File
@@ -4078,6 +4078,7 @@
"fileAvailability": "File Availability",
"rating": "Rating",
"communityRating": "Community Rating",
"communityRatingCount": "Community Review Count",
"readProgress": "Reading Progress",
"readStatus": "Read Status",
"description": "Description",
+6 -2
View File
@@ -7,6 +7,7 @@ import type { CommunityRatingProviderKey } from "./metadata-fetch";
* Most fields map directly to a DB column. The exceptions:
* - `fileAvailability` - derived from `books.status` ('present' | 'missing')
* - `communityRating` - from `book_community_ratings.rating` (optionally provider-specific)
* - `communityRatingCount` - from `book_community_ratings.rating_count` (optionally provider-specific)
* - `readProgress` - aggregated from `reading_progress.percentage` (per-user, per-book-file)
* - `readStatus` - stored in `user_book_status.status` (per-user)
* - `startedAt` - from `user_book_status.started_at` (per-user)
@@ -47,6 +48,7 @@ export type StaticRuleField =
| "fileAvailability"
| "rating"
| "communityRating"
| "communityRatingCount"
| "readProgress"
| "readStatus"
| "description"
@@ -122,6 +124,7 @@ export const FIELD_OPERATORS: Record<StaticRuleField, RuleOperator[]> = {
fileAvailability: ["isMissing", "isPresent"],
rating: ["eq", "gt", "gte", "lt", "lte", "isEmpty", "isNotEmpty"],
communityRating: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
communityRatingCount: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
readProgress: ["isUnread", "isInProgress", "isFinished"],
readStatus: ["includesAny", "excludesAll", "isEmpty", "isNotEmpty"],
description: ["isEmpty", "isNotEmpty"],
@@ -189,11 +192,12 @@ export const RULE_OPERATORS: RuleOperator[] = [
];
export type CommunityRatingProvider = CommunityRatingProviderKey | "any";
export type CommunityRatingRuleField = "communityRating" | "communityRatingCount";
export type RuleValue = string | number | string[] | number[];
export type StandardRule = {
type: "rule";
field: Exclude<StaticRuleField, "communityRating">;
field: Exclude<StaticRuleField, CommunityRatingRuleField>;
operator: RuleOperator;
value?: RuleValue;
valueTo?: string | number;
@@ -201,7 +205,7 @@ export type StandardRule = {
export type CommunityRatingRule = {
type: "rule";
field: "communityRating";
field: CommunityRatingRuleField;
operator: RuleOperator;
provider?: CommunityRatingProvider;
value?: RuleValue;
@@ -0,0 +1,2 @@
CREATE INDEX "book_community_ratings_count_book_idx" ON "book_community_ratings" USING btree ("rating_count","book_id");--> statement-breakpoint
CREATE INDEX "book_community_ratings_provider_count_book_idx" ON "book_community_ratings" USING btree ("provider","rating_count","book_id");
File diff suppressed because it is too large Load Diff
@@ -575,6 +575,13 @@
"when": 1787931558680,
"tag": "0081_add_public_collections",
"breakpoints": true
},
{
"idx": 82,
"version": "7",
"when": 1788151243360,
"tag": "0082_add_community_rating_count_filter_indexes",
"breakpoints": true
}
]
}
+2
View File
@@ -155,6 +155,8 @@ export const bookCommunityRatings = pgTable(
index('book_community_ratings_provider_idx').on(t.provider),
index('book_community_ratings_rating_book_idx').on(t.rating, t.bookId),
index('book_community_ratings_provider_rating_book_idx').on(t.provider, t.rating, t.bookId),
index('book_community_ratings_count_book_idx').on(t.ratingCount, t.bookId),
index('book_community_ratings_provider_count_book_idx').on(t.provider, t.ratingCount, t.bookId),
check('book_community_ratings_rating_range_chk', sql`${t.rating} >= 0 and ${t.rating} <= 5`),
check('book_community_ratings_count_nonnegative_chk', sql`${t.ratingCount} is null or ${t.ratingCount} >= 0`),
],
@@ -136,7 +136,16 @@ const USER_CTX = { accessibleLibraryIds: [1] as number[], userId: 10 };
* new operator is added to RuleOperator but not handled here.
*/
function buildValueFor(operator: RuleOperator, field: RuleField): { value?: unknown; valueTo?: unknown } {
const numericFields: RuleField[] = ['publishedYear', 'seriesIndex', 'pageCount', 'fileSize', 'rating', 'communityRating', 'metadataScore'];
const numericFields: RuleField[] = [
'publishedYear',
'seriesIndex',
'pageCount',
'fileSize',
'rating',
'communityRating',
'communityRatingCount',
'metadataScore',
];
const dateFields: RuleField[] = ['publishedDate', 'addedAt', 'startedAt', 'finishedAt'];
const isNumericField = numericFields.includes(field);
@@ -554,6 +563,89 @@ describe('communityRating filter field', () => {
});
});
describe('communityRatingCount filter field', () => {
it('matches any provider count with the requested numeric predicate', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'communityRatingCount', operator: 'gte', value: 1000, provider: 'any' }) as never,
USER_CTX,
) as any;
const ruleSql = getRuleSql(where) as any;
expect(ruleSql).toMatchObject({ type: 'sql' });
expect(ruleSql.values[0].whereClause.clauses).toHaveLength(2);
expect(ruleSql.values[0].whereClause.clauses[1]).toMatchObject({ type: 'gte', right: 1000 });
expect(collectColumnNames(ruleSql)).toContain('rating_count');
});
it('matches a specific provider count in the same exists subquery', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'communityRatingCount', operator: 'lt', value: 25, provider: 'hardcover' }) as never,
USER_CTX,
) as any;
const predicates = (getRuleSql(where) as any).values[0].whereClause.clauses;
expect(predicates).toHaveLength(3);
expect(predicates[1]).toMatchObject({ type: 'eq', right: 'hardcover' });
expect(predicates[2]).toMatchObject({ type: 'lt', right: 25 });
expect(collectColumnNames(predicates[2])).toContain('rating_count');
});
it('treats a missing row or null count as empty', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'communityRatingCount', operator: 'isEmpty', provider: 'goodreads' }) as never,
USER_CTX,
) as any;
const ruleSql = getRuleSql(where) as any;
expect(ruleSql).toMatchObject({ type: 'not' });
expect(ruleSql.value.values[0].whereClause.clauses[1]).toMatchObject({ type: 'eq', right: 'goodreads' });
expect(ruleSql.value.values[0].whereClause.clauses[2]).toMatchObject({ type: 'isNotNull' });
expect(collectColumnNames(ruleSql.value.values[0].whereClause.clauses[2])).toContain('rating_count');
});
it('requires a non-null count for isNotEmpty', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'communityRatingCount', operator: 'isNotEmpty', provider: 'goodreads' }) as never,
USER_CTX,
) as any;
const predicates = (getRuleSql(where) as any).values[0].whereClause.clauses;
expect(predicates[1]).toMatchObject({ type: 'eq', right: 'goodreads' });
expect(predicates[2]).toMatchObject({ type: 'isNotNull' });
expect(collectColumnNames(predicates[2])).toContain('rating_count');
});
it('keeps rating and count predicates provider-specific in an AND group', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
{
type: 'group',
join: 'AND',
rules: [
{ type: 'rule', field: 'communityRating', operator: 'gte', value: 4.5, provider: 'amazon' },
{ type: 'rule', field: 'communityRatingCount', operator: 'gte', value: 1000, provider: 'amazon' },
],
} as never,
USER_CTX,
) as any;
const [ratingRule, countRule] = where.clauses[1].clauses;
expect(ratingRule.values[0].whereClause.clauses[1]).toMatchObject({ type: 'eq', right: 'amazon' });
expect(countRule.values[0].whereClause.clauses[1]).toMatchObject({ type: 'eq', right: 'amazon' });
expect(collectColumnNames(ratingRule)).toContain('rating');
expect(collectColumnNames(countRule)).toContain('rating_count');
});
});
describe('readStatus filter field', () => {
it('includesAny generates exists with inArray', () => {
const { builder, db } = makeBuilder();
@@ -173,7 +173,23 @@ export class BookQueryBuilder {
if (userId === undefined) throw new BadRequestException('rating filter requires an authenticated user');
return this.ratingRuleToSql(operator, value as number | undefined, valueTo as number | undefined, userId);
case 'communityRating':
return this.communityRatingRuleToSql(operator, value as number | undefined, valueTo as number | undefined, rule.provider);
return this.communityRatingRuleToSql(
bookCommunityRatings.rating,
'communityRating',
operator,
value as number | undefined,
valueTo as number | undefined,
rule.provider,
);
case 'communityRatingCount':
return this.communityRatingRuleToSql(
bookCommunityRatings.ratingCount,
'communityRatingCount',
operator,
value as number | undefined,
valueTo as number | undefined,
rule.provider,
);
case 'author':
return this.authorRuleToSql(operator, value as string[]);
case 'genre':
@@ -516,6 +532,8 @@ export class BookQueryBuilder {
}
private communityRatingRuleToSql(
metricColumn: AnyColumn,
field: 'communityRating' | 'communityRatingCount',
operator: string,
value: number | undefined,
valueTo: number | undefined,
@@ -536,32 +554,32 @@ export class BookQueryBuilder {
switch (operator) {
case 'eq':
this.assertNumber(value, operator, 'value');
return existsCommunityRating(eq(bookCommunityRatings.rating, value!));
return existsCommunityRating(eq(metricColumn, value!));
case 'notEq':
this.assertNumber(value, operator, 'value');
return existsCommunityRating(ne(bookCommunityRatings.rating, value!));
return existsCommunityRating(ne(metricColumn, value!));
case 'gt':
this.assertNumber(value, operator, 'value');
return existsCommunityRating(gt(bookCommunityRatings.rating, value!));
return existsCommunityRating(gt(metricColumn, value!));
case 'gte':
this.assertNumber(value, operator, 'value');
return existsCommunityRating(gte(bookCommunityRatings.rating, value!));
return existsCommunityRating(gte(metricColumn, value!));
case 'lt':
this.assertNumber(value, operator, 'value');
return existsCommunityRating(lt(bookCommunityRatings.rating, value!));
return existsCommunityRating(lt(metricColumn, value!));
case 'lte':
this.assertNumber(value, operator, 'value');
return existsCommunityRating(lte(bookCommunityRatings.rating, value!));
return existsCommunityRating(lte(metricColumn, value!));
case 'between':
this.assertNumber(value, operator, 'value');
this.assertNumber(valueTo, operator, 'valueTo');
return existsCommunityRating(and(gte(bookCommunityRatings.rating, value!), lte(bookCommunityRatings.rating, valueTo!))!);
return existsCommunityRating(and(gte(metricColumn, value!), lte(metricColumn, valueTo!))!);
case 'isEmpty':
return not(existsCommunityRating());
return not(existsCommunityRating(isNotNull(metricColumn)));
case 'isNotEmpty':
return existsCommunityRating();
return existsCommunityRating(isNotNull(metricColumn));
default:
throw new BadRequestException(`Invalid operator '${operator}' for communityRating field`);
throw new BadRequestException(`Invalid operator '${operator}' for ${field} field`);
}
}
@@ -213,6 +213,16 @@ describe('validateGroupRule', () => {
expect(validateGroupRule(rule)).toEqual(rule);
});
it('accepts a community rating count rule with an explicit provider', () => {
const rule = {
type: 'group',
join: 'AND',
rules: [{ type: 'rule', field: 'communityRatingCount', operator: 'gte', value: 1000, provider: COMMUNITY_RATING_PROVIDER_KEYS[0] }],
};
expect(validateGroupRule(rule)).toEqual(rule);
});
it('accepts a community rating rule without provider for backwards compatibility', () => {
const rule = {
type: 'group',
@@ -243,6 +253,16 @@ describe('validateGroupRule', () => {
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
});
it('rejects unsupported providers on community rating count rules', () => {
const rule = {
type: 'group',
join: 'AND',
rules: [{ type: 'rule', field: 'communityRatingCount', operator: 'gte', value: 1000, provider: 'kobo' }],
};
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
});
it('accepts array values for includesAny operators', () => {
const rule = {
type: 'group',
@@ -93,8 +93,12 @@ const ruleSchema: z.ZodType<Rule> = z
if (!allowedOperators?.includes(rule.operator as RuleOperator)) {
ctx.addIssue({ code: 'custom', message: 'Operator is not valid for this field', path: ['operator'] });
}
if (rule.provider !== undefined && rule.field !== 'communityRating') {
ctx.addIssue({ code: 'custom', message: 'Provider is only valid for community rating rules', path: ['provider'] });
if (rule.provider !== undefined && rule.field !== 'communityRating' && rule.field !== 'communityRatingCount') {
ctx.addIssue({
code: 'custom',
message: 'Provider is only valid for community rating rules and community rating count rules',
path: ['provider'],
});
}
if (custom) {
checkCustomFieldValue(rule, ctx);