mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(stats): add Speed trait and 4 new archetypes to Reading DNA widget
This commit is contained in:
@@ -10,6 +10,7 @@ const traitColors: Record<string, string> = {
|
||||
variety: 'bg-emerald-500',
|
||||
rhythm: 'bg-amber-500',
|
||||
time: 'bg-purple-500',
|
||||
speed: 'bg-rose-500',
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -23,7 +24,7 @@ const traitColors: Record<string, string> = {
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex flex-1 flex-col items-center justify-center gap-3">
|
||||
<div class="h-4 w-32 animate-pulse rounded bg-muted" />
|
||||
<div v-for="n in 4" :key="n" class="h-2 w-full animate-pulse rounded-full bg-muted" />
|
||||
<div v-for="n in 5" :key="n" class="h-2 w-full animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
@@ -48,6 +49,7 @@ const traitColors: Record<string, string> = {
|
||||
{ key: 'variety', label: 'Variety', score: data.varietyScore, valueLabel: data.varietyLabel },
|
||||
{ key: 'rhythm', label: 'Rhythm', score: data.rhythmScore, valueLabel: data.rhythmLabel },
|
||||
{ key: 'time', label: 'Time', score: data.timeScore, valueLabel: data.timeLabel },
|
||||
{ key: 'speed', label: 'Speed', score: data.speedScore, valueLabel: data.speedLabel },
|
||||
]"
|
||||
:key="trait.key"
|
||||
class="flex items-center gap-2"
|
||||
@@ -56,7 +58,7 @@ const traitColors: Record<string, string> = {
|
||||
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div class="h-full rounded-full transition-all duration-500" :class="traitColors[trait.key]" :style="{ width: `${trait.score}%` }" />
|
||||
</div>
|
||||
<span class="w-14 text-[10px] text-muted-foreground">{{ trait.valueLabel }}</span>
|
||||
<span class="w-18 text-[10px] text-muted-foreground">{{ trait.valueLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -92,13 +92,7 @@ export interface HighlightOfTheDayWidgetData {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type ChallengeType =
|
||||
| "short-read"
|
||||
| "genre-explorer"
|
||||
| "finish-oldest"
|
||||
| "streak-builder"
|
||||
| "new-author"
|
||||
| "page-milestone";
|
||||
export type ChallengeType = "short-read" | "genre-explorer" | "finish-oldest" | "streak-builder" | "new-author" | "page-milestone";
|
||||
|
||||
export interface MonthlyChallengeWidgetData {
|
||||
challengeType: ChallengeType;
|
||||
@@ -139,10 +133,12 @@ export interface ReadingDnaWidgetData {
|
||||
varietyScore: number;
|
||||
rhythmScore: number;
|
||||
timeScore: number;
|
||||
speedScore: number;
|
||||
lengthLabel: string;
|
||||
varietyLabel: string;
|
||||
rhythmLabel: string;
|
||||
timeLabel: string;
|
||||
speedLabel: string;
|
||||
booksAnalyzed: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
computeReadingDna,
|
||||
computeRhythm,
|
||||
computeRhythmScore,
|
||||
computeSpeedScore,
|
||||
computeTimeScore,
|
||||
computeVarietyScore,
|
||||
findEligibleChallenges,
|
||||
@@ -310,9 +311,36 @@ describe('Reading DNA calculations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeSpeedScore', () => {
|
||||
it('returns 0 for 0 pages/hour', () => {
|
||||
expect(computeSpeedScore(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('caps at 100 for very fast readers', () => {
|
||||
expect(computeSpeedScore(80)).toBe(100);
|
||||
expect(computeSpeedScore(200)).toBe(100);
|
||||
});
|
||||
|
||||
it('scores ~50 at 40 pages/hour (mid-range)', () => {
|
||||
expect(computeSpeedScore(40)).toBe(50);
|
||||
});
|
||||
|
||||
it('returns Slow Savorer threshold boundary: score 30 at 24 pages/hour', () => {
|
||||
expect(computeSpeedScore(24)).toBe(30);
|
||||
});
|
||||
|
||||
it('returns Steady Pacer threshold boundary: score 60 at 48 pages/hour', () => {
|
||||
expect(computeSpeedScore(48)).toBe(60);
|
||||
});
|
||||
|
||||
it('clamps to 0 for negative input', () => {
|
||||
expect(computeSpeedScore(-10)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeReadingDna', () => {
|
||||
it('returns valid archetype and scores', () => {
|
||||
const dna = computeReadingDna(300, 5, 20, 0.8, 21);
|
||||
const dna = computeReadingDna(300, 5, 20, 0.8, 21, null);
|
||||
expect(dna.archetype).toBeTruthy();
|
||||
expect(dna.lengthScore).toBeGreaterThanOrEqual(0);
|
||||
expect(dna.lengthScore).toBeLessThanOrEqual(100);
|
||||
@@ -321,15 +349,67 @@ describe('Reading DNA calculations', () => {
|
||||
expect(dna.varietyLabel).toBeTruthy();
|
||||
expect(dna.rhythmLabel).toBeTruthy();
|
||||
expect(dna.timeLabel).toBeTruthy();
|
||||
expect(dna.speedLabel).toBeTruthy();
|
||||
});
|
||||
|
||||
it('identifies Deep Specialist for low variety', () => {
|
||||
const dna = computeReadingDna(200, 1, 20, 0.5, 12);
|
||||
const dna = computeReadingDna(200, 1, 20, 0.5, 12, null);
|
||||
expect(dna.archetype).toBe('Deep Specialist');
|
||||
});
|
||||
|
||||
it('identifies Steady Eclectic Explorer', () => {
|
||||
const dna = computeReadingDna(400, 15, 20, 0.9, 14);
|
||||
const dna = computeReadingDna(400, 15, 20, 0.9, 14, null);
|
||||
expect(dna.archetype).toBe('Steady Eclectic Explorer');
|
||||
});
|
||||
|
||||
it('returns speedScore=0 and speedLabel="N/A" when no speed data', () => {
|
||||
const dna = computeReadingDna(300, 5, 20, 0.5, 14, null);
|
||||
expect(dna.speedScore).toBe(0);
|
||||
expect(dna.speedLabel).toBe('N/A');
|
||||
});
|
||||
|
||||
it('returns speedLabel for valid speed data', () => {
|
||||
const dna = computeReadingDna(300, 5, 20, 0.5, 14, 30);
|
||||
expect(dna.speedScore).toBeGreaterThan(0);
|
||||
expect(dna.speedLabel).not.toBe('N/A');
|
||||
});
|
||||
|
||||
it('identifies Slow Savorer for slow speed and long books', () => {
|
||||
// avgPageCount=600 -> lengthScore=100 (>60), speed=10 pages/hr -> speedScore=12 (<=30)
|
||||
const dna = computeReadingDna(600, 5, 20, 0.5, 14, 10);
|
||||
expect(dna.archetype).toBe('Slow Savorer');
|
||||
});
|
||||
|
||||
it('identifies Rapid Explorer for fast speed and eclectic variety', () => {
|
||||
// uniqueGenres=20, totalBooks=20 -> varietyScore=100 (>60), speed=80 pages/hr -> speedScore=100 (>60)
|
||||
const dna = computeReadingDna(300, 20, 20, 0.5, 14, 80);
|
||||
expect(dna.archetype).toBe('Rapid Explorer');
|
||||
});
|
||||
|
||||
it('identifies Speed Demon for fast speed and bursty rhythm', () => {
|
||||
// readingDaysRatio=0.1 -> rhythmScore=10 (<=30), speed=80 pages/hr -> speedScore=100 (>60)
|
||||
// variety must be <= 60 so Rapid Explorer does not trigger first
|
||||
const dna = computeReadingDna(300, 3, 10, 0.1, 14, 80);
|
||||
expect(dna.archetype).toBe('Speed Demon');
|
||||
});
|
||||
|
||||
it('identifies Patient Specialist for slow speed and focused variety', () => {
|
||||
// uniqueGenres=1, totalBooks=20 -> varietyScore=10 (<=30), speed=10 pages/hr -> speedScore=12 (<=30)
|
||||
// rhythmScore must be > 30 so Weekend Binge Reader does not win
|
||||
const dna = computeReadingDna(300, 1, 20, 0.5, 14, 10);
|
||||
expect(dna.archetype).toBe('Patient Specialist');
|
||||
});
|
||||
|
||||
it('skips speed archetypes when speed data is null even if scores would otherwise match', () => {
|
||||
// Same conditions as Patient Specialist but no speed data -> falls to Deep Specialist
|
||||
const dna = computeReadingDna(300, 1, 20, 0.5, 14, null);
|
||||
expect(dna.archetype).toBe('Deep Specialist');
|
||||
});
|
||||
|
||||
it('speed archetypes do not override higher-priority 3-condition archetypes', () => {
|
||||
// long + eclectic + steady -> Steady Eclectic Explorer (priority 2)
|
||||
// also has fast speed + eclectic -> would be Rapid Explorer (priority 6)
|
||||
const dna = computeReadingDna(600, 20, 20, 0.9, 14, 80);
|
||||
expect(dna.archetype).toBe('Steady Eclectic Explorer');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,6 +237,10 @@ export function computeTimeScore(peakHour: number): number {
|
||||
return Math.min(100, Math.round((peakHour / 23) * 100));
|
||||
}
|
||||
|
||||
export function computeSpeedScore(avgPagesPerHour: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round((avgPagesPerHour / 80) * 100)));
|
||||
}
|
||||
|
||||
function getLengthLabel(score: number): string {
|
||||
if (score <= 30) return 'Short';
|
||||
if (score <= 60) return 'Medium';
|
||||
@@ -262,11 +266,18 @@ function getTimeLabel(peakHour: number): string {
|
||||
return 'Evening';
|
||||
}
|
||||
|
||||
function getSpeedLabel(score: number): string {
|
||||
if (score <= 30) return 'Slow Savorer';
|
||||
if (score <= 60) return 'Steady Pacer';
|
||||
return 'Speed Demon';
|
||||
}
|
||||
|
||||
interface DnaTraits {
|
||||
lengthScore: number;
|
||||
varietyScore: number;
|
||||
rhythmScore: number;
|
||||
timeLabel: string;
|
||||
speedScore: number | null;
|
||||
}
|
||||
|
||||
const ARCHETYPES: [predicate: (traits: DnaTraits) => boolean, label: string][] = [
|
||||
@@ -274,6 +285,10 @@ const ARCHETYPES: [predicate: (traits: DnaTraits) => boolean, label: string][] =
|
||||
[({ lengthScore, varietyScore, rhythmScore }) => lengthScore > 60 && varietyScore > 60 && rhythmScore > 60, 'Steady Eclectic Explorer'],
|
||||
[({ varietyScore, rhythmScore }) => varietyScore > 60 && rhythmScore > 60, 'Disciplined Genre Hopper'],
|
||||
[({ lengthScore, timeLabel }) => lengthScore > 60 && timeLabel === 'Early Bird', 'Early Bird Marathon Reader'],
|
||||
[({ speedScore, lengthScore }) => speedScore !== null && speedScore <= 30 && lengthScore > 60, 'Slow Savorer'],
|
||||
[({ speedScore, varietyScore }) => speedScore !== null && speedScore > 60 && varietyScore > 60, 'Rapid Explorer'],
|
||||
[({ speedScore, rhythmScore }) => speedScore !== null && speedScore > 60 && rhythmScore <= 30, 'Speed Demon'],
|
||||
[({ speedScore, varietyScore }) => speedScore !== null && speedScore <= 30 && varietyScore <= 30, 'Patient Specialist'],
|
||||
[({ varietyScore }) => varietyScore <= 30, 'Deep Specialist'],
|
||||
[({ rhythmScore }) => rhythmScore <= 30, 'Weekend Binge Reader'],
|
||||
[({ varietyScore }) => varietyScore > 60, 'Curious Explorer'],
|
||||
@@ -286,16 +301,18 @@ export function computeReadingDna(
|
||||
totalBooks: number,
|
||||
readingDaysRatio: number,
|
||||
peakHour: number,
|
||||
avgPagesPerHour: number | null,
|
||||
): ReadingDnaWidgetData {
|
||||
const lengthScore = computeLengthScore(avgPageCount);
|
||||
const varietyScore = computeVarietyScore(uniqueGenres, totalBooks);
|
||||
const rhythmScore = computeRhythmScore(readingDaysRatio);
|
||||
const timeScore = computeTimeScore(peakHour);
|
||||
const timeLabel = getTimeLabel(peakHour);
|
||||
const speedScore = avgPagesPerHour !== null ? computeSpeedScore(avgPagesPerHour) : null;
|
||||
|
||||
let archetype = 'Avid Reader';
|
||||
for (const [predicate, label] of ARCHETYPES) {
|
||||
if (predicate({ lengthScore, varietyScore, rhythmScore, timeLabel })) {
|
||||
if (predicate({ lengthScore, varietyScore, rhythmScore, timeLabel, speedScore })) {
|
||||
archetype = label;
|
||||
break;
|
||||
}
|
||||
@@ -307,10 +324,12 @@ export function computeReadingDna(
|
||||
varietyScore,
|
||||
rhythmScore,
|
||||
timeScore,
|
||||
speedScore: speedScore ?? 0,
|
||||
lengthLabel: getLengthLabel(lengthScore),
|
||||
varietyLabel: getVarietyLabel(varietyScore),
|
||||
rhythmLabel: getRhythmLabel(rhythmScore),
|
||||
timeLabel,
|
||||
speedLabel: speedScore !== null ? getSpeedLabel(speedScore) : 'N/A',
|
||||
booksAnalyzed: totalBooks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -630,15 +630,16 @@ export class DashboardWidgetRepository {
|
||||
totalBooks: number;
|
||||
readingDaysRatio: number;
|
||||
peakHour: number;
|
||||
avgPagesPerHour: number | null;
|
||||
}> {
|
||||
if (accessibleLibraryIds.length === 0) {
|
||||
return { avgPageCount: 0, uniqueGenres: 0, totalBooks: 0, readingDaysRatio: 0, peakHour: 12 };
|
||||
return { avgPageCount: 0, uniqueGenres: 0, totalBooks: 0, readingDaysRatio: 0, peakHour: 12, avgPagesPerHour: null };
|
||||
}
|
||||
|
||||
const libFilter = inArray(books.libraryId, accessibleLibraryIds);
|
||||
const presentFilter = eq(books.status, 'present');
|
||||
|
||||
const [[statsRow], [genreRow], dailyRows, [peakRow]] = await Promise.all([
|
||||
const [[statsRow], [genreRow], dailyRows, [peakRow], [knownSpeedRow], [unknownSpeedRow]] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
avg: sql<number>`coalesce(avg(${bookMetadata.pageCount}), 0)::int`,
|
||||
@@ -680,10 +681,60 @@ export class DashboardWidgetRepository {
|
||||
.groupBy(sql`extract(hour from ${readingSessions.startedAt})`)
|
||||
.orderBy(desc(sql`sum(${readingSessions.durationSeconds})`))
|
||||
.limit(1),
|
||||
this.db
|
||||
.select({
|
||||
totalPages: sql<number>`coalesce(sum(${bookMetadata.pageCount} * ${readingSessions.progressDelta} / 100.0), 0)::float`,
|
||||
totalSeconds: sql<number>`coalesce(sum(${readingSessions.durationSeconds}), 0)::float`,
|
||||
})
|
||||
.from(readingSessions)
|
||||
.innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId))
|
||||
.innerJoin(books, eq(books.id, bookFiles.bookId))
|
||||
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.where(
|
||||
and(
|
||||
eq(readingSessions.userId, userId),
|
||||
libFilter,
|
||||
presentFilter,
|
||||
gte(readingSessions.startedAt, since),
|
||||
isNotNull(readingSessions.progressDelta),
|
||||
gt(readingSessions.progressDelta, 0),
|
||||
sql`${readingSessions.progressDelta} <= 100`,
|
||||
gt(readingSessions.durationSeconds, 0),
|
||||
isNotNull(bookMetadata.pageCount),
|
||||
gt(bookMetadata.pageCount, 0),
|
||||
),
|
||||
),
|
||||
this.db
|
||||
.select({
|
||||
totalProgress: sql<number>`coalesce(sum(${readingSessions.progressDelta}), 0)::float`,
|
||||
totalSeconds: sql<number>`coalesce(sum(${readingSessions.durationSeconds}), 0)::float`,
|
||||
})
|
||||
.from(readingSessions)
|
||||
.innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId))
|
||||
.innerJoin(books, eq(books.id, bookFiles.bookId))
|
||||
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.where(
|
||||
and(
|
||||
eq(readingSessions.userId, userId),
|
||||
libFilter,
|
||||
presentFilter,
|
||||
gte(readingSessions.startedAt, since),
|
||||
isNotNull(readingSessions.progressDelta),
|
||||
gt(readingSessions.progressDelta, 0),
|
||||
sql`${readingSessions.progressDelta} <= 100`,
|
||||
gt(readingSessions.durationSeconds, 0),
|
||||
isNull(bookMetadata.pageCount),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const daysSinceLookback = Math.max(1, Math.ceil((Date.now() - since.getTime()) / (1000 * 60 * 60 * 24)));
|
||||
const activeDays = dailyRows.filter((r) => r.seconds > 0).length;
|
||||
const inferredPageCount = (statsRow?.avg ?? 0) > 0 ? (statsRow?.avg ?? 0) : DEFAULT_VIRTUAL_PAGE_COUNT;
|
||||
const pagesFromKnownSpeed = knownSpeedRow?.totalPages ?? 0;
|
||||
const pagesFromUnknownSpeed = ((unknownSpeedRow?.totalProgress ?? 0) * inferredPageCount) / 100;
|
||||
const speedSeconds = (knownSpeedRow?.totalSeconds ?? 0) + (unknownSpeedRow?.totalSeconds ?? 0);
|
||||
const avgPagesPerHour = speedSeconds > 0 ? (pagesFromKnownSpeed + pagesFromUnknownSpeed) / (speedSeconds / 3600) : null;
|
||||
|
||||
return {
|
||||
avgPageCount: statsRow?.avg ?? 0,
|
||||
@@ -691,6 +742,7 @@ export class DashboardWidgetRepository {
|
||||
totalBooks: statsRow?.total ?? 0,
|
||||
readingDaysRatio: activeDays / daysSinceLookback,
|
||||
peakHour: peakRow?.hour ?? 12,
|
||||
avgPagesPerHour,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -280,12 +280,32 @@ describe('DashboardWidgetService', () => {
|
||||
totalBooks: 25,
|
||||
readingDaysRatio: 0.7,
|
||||
peakHour: 21,
|
||||
avgPagesPerHour: 45,
|
||||
});
|
||||
|
||||
const result = await service.getReadingDna(makeUser());
|
||||
expect(result.archetype).toBeTruthy();
|
||||
expect(result.booksAnalyzed).toBe(25);
|
||||
expect(result.timeLabel).toBe('Evening');
|
||||
expect(result.speedLabel).toBe('Steady Pacer');
|
||||
expect(result.speedScore).toBe(56);
|
||||
});
|
||||
|
||||
it('returns speedLabel N/A when no speed data is available', async () => {
|
||||
const { service, widgetRepo, libraryService } = makeService();
|
||||
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
|
||||
widgetRepo.getReadingDnaData.mockResolvedValue({
|
||||
avgPageCount: 350,
|
||||
uniqueGenres: 8,
|
||||
totalBooks: 25,
|
||||
readingDaysRatio: 0.7,
|
||||
peakHour: 21,
|
||||
avgPagesPerHour: null,
|
||||
});
|
||||
|
||||
const result = await service.getReadingDna(makeUser());
|
||||
expect(result.speedLabel).toBe('N/A');
|
||||
expect(result.speedScore).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ export class DashboardWidgetService {
|
||||
|
||||
const data = await this.widgetRepo.getReadingDnaData(user.id, accessibleLibraryIds, since);
|
||||
|
||||
return computeReadingDna(data.avgPageCount, data.uniqueGenres, data.totalBooks, data.readingDaysRatio, data.peakHour);
|
||||
return computeReadingDna(data.avgPageCount, data.uniqueGenres, data.totalBooks, data.readingDaysRatio, data.peakHour, data.avgPagesPerHour);
|
||||
}
|
||||
|
||||
async getLongWait(user: RequestUser): Promise<LongWaitWidgetData | null> {
|
||||
|
||||
Reference in New Issue
Block a user