feat(stats): add Speed trait and 4 new archetypes to Reading DNA widget

This commit is contained in:
neon
2026-04-28 13:50:26 -06:00
parent 006c2c4948
commit a41ce07834
7 changed files with 185 additions and 16 deletions
@@ -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>
+3 -7
View File
@@ -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> {