feat: Enhance Stats With Genre Taxonomy and Charts (#1919)

* Add react-native-svg and refresh stats UI

Normalize status counts, add donut chart with legend, and improve bars

* Add genre stats screen and taxonomy link

* Update StatsQueries.test.ts

* change genre taxonomy style

* Use LegendList instead of ScrollView

* added more colors to chart

* Add Highlight animation

* Add ChartLegend animation

* Maintainance

* updated stats queries

* Added animations to collapsibles and optimized performance

* Extract tabs

* update time tracking tab
This commit is contained in:
Patrick Loser
2026-08-01 08:01:03 +02:00
committed by GitHub
parent 76106317b6
commit 96fb52df17
31 changed files with 2942 additions and 722 deletions
+1
View File
@@ -94,6 +94,7 @@
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "^4.25.2",
"react-native-shimmer-placeholder": "^2.0.9",
"react-native-svg": "15.15.4",
"react-native-tab-view": "^4.3.2",
"react-native-url-polyfill": "^3.0.0",
"react-native-webview": "^13.16.1",
+31
View File
@@ -185,6 +185,9 @@ importers:
react-native-shimmer-placeholder:
specifier: ^2.0.9
version: 2.0.9(prop-types@15.8.1)(react-native-linear-gradient@2.8.3(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3))
react-native-svg:
specifier: 15.15.4
version: 15.15.4(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)
react-native-tab-view:
specifier: ^4.3.2
version: 4.3.2(react-native-pager-view@8.0.4(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)
@@ -2751,6 +2754,10 @@ packages:
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
css-tree@1.1.3:
resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==}
engines: {node: '>=8.0.0'}
css-what@6.2.2:
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
engines: {node: '>= 6'}
@@ -4578,6 +4585,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mdn-data@2.0.14:
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -5258,6 +5268,12 @@ packages:
prop-types: '>=15.6.0'
react-native-linear-gradient: '>=2.4.0'
react-native-svg@15.15.4:
resolution: {integrity: sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==}
peerDependencies:
react: '*'
react-native: '*'
react-native-tab-view@4.3.2:
resolution: {integrity: sha512-QCHX9bTGMeME5pt4jJ1/H7oshX7Z2nA/Ts3m7FpKUbWfPPIL8rlNLZwkedgV273Eqau8jj8deRtfSZl9LCctCw==}
peerDependencies:
@@ -9236,6 +9252,11 @@ snapshots:
domutils: 3.2.2
nth-check: 2.1.1
css-tree@1.1.3:
dependencies:
mdn-data: 2.0.14
source-map: 0.6.1
css-what@6.2.2: {}
cssom@0.3.8: {}
@@ -11412,6 +11433,8 @@ snapshots:
math-intrinsics@1.1.0: {}
mdn-data@2.0.14: {}
media-typer@0.3.0: {}
media-typer@1.1.1: {}
@@ -12195,6 +12218,14 @@ snapshots:
prop-types: 15.8.1
react-native-linear-gradient: 2.8.3(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)
react-native-svg@15.15.4(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3):
dependencies:
css-select: 5.2.2
css-tree: 1.1.3
react: 19.2.3
react-native: 0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0)
warn-once: 0.1.1
react-native-tab-view@4.3.2(react-native-pager-view@8.0.4(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3):
dependencies:
react: 19.2.3
+95 -159
View File
@@ -1,182 +1,118 @@
import { countBy } from 'lodash-es';
import { eq, count, and, sql } from 'drizzle-orm';
import { eq, count, sql } from 'drizzle-orm';
import { LibraryStats } from '../types';
import { dbManager } from '@database/db';
import { novelSchema, chapterSchema, categorySchema, novelCategorySchema } from '@database/schema';
/**
* Get library statistics (novel count and distinct sources) using Drizzle ORM
*/
export const getLibraryStatsFromDb = async (): Promise<LibraryStats> => {
const result = await dbManager
.select({
novelsCount: count(),
sourcesCount: sql<number>`COUNT(DISTINCT ${novelSchema.pluginId})`,
})
.from(novelSchema)
.where(eq(novelSchema.inLibrary, true))
.get();
return result ?? { novelsCount: 0, sourcesCount: 0 };
};
/**
* Get total chapters count for all novels in library
*/
export const getChaptersTotalCountFromDb = async (): Promise<LibraryStats> => {
const result = await dbManager
.select({ chaptersCount: count() })
.from(chapterSchema)
.innerJoin(novelSchema, eq(chapterSchema.novelId, novelSchema.id))
.where(eq(novelSchema.inLibrary, true))
.get();
return result ?? { chaptersCount: 0 };
};
import {
novelSchema,
chapterSchema,
categorySchema,
novelCategorySchema,
} from '@database/schema';
/**
* Get top novels based on time spent
*/
export const getTopNovelsByTimeSpentFromDb = async (): Promise<LibraryStats> => {
const result = await dbManager
.select({
id: novelSchema.id,
pluginId: novelSchema.pluginId,
cover: novelSchema.cover,
name: novelSchema.name,
timeSpent: sql<number>`SUM(${chapterSchema.timeSpent})`,
})
.from(novelSchema)
.innerJoin(chapterSchema, eq(chapterSchema.novelId, novelSchema.id))
.where(eq(novelSchema.inLibrary, true))
.groupBy(novelSchema.id)
.orderBy(sql`SUM(${chapterSchema.timeSpent}) DESC`)
.having(sql`SUM(${chapterSchema.timeSpent}) > 0`)
.limit(10)
.all();
return { topNovelsByTimeSpent: result };
};
/** Get top categories based on time spent */
export const getTopCategoriesByTimeSpentFromDb = async (): Promise<LibraryStats> => {
const result = await dbManager
.select({
id: categorySchema.id,
name: categorySchema.name,
timeSpent: sql<number>`SUM(${chapterSchema.timeSpent})`
})
.from(categorySchema)
.innerJoin(novelCategorySchema, eq(categorySchema.id, novelCategorySchema.categoryId))
.innerJoin(novelSchema, eq(novelSchema.id, novelCategorySchema.novelId))
.innerJoin(chapterSchema, eq(novelSchema.id, chapterSchema.novelId))
.groupBy(categorySchema.id)
.orderBy(sql`SUM(${chapterSchema.timeSpent}) DESC`)
.having(sql`SUM(${chapterSchema.timeSpent}) > 0`)
.limit(10)
.all()
return { topCategoriesByTimeSpent: result };
}
/** Sum up the timeSpent for all chapters */
export const getTotalTimeSpentFromDb = async (): Promise<LibraryStats> => {
const result = await dbManager
.select({ totalTimeSpent: sql<number>`COALESCE(SUM(${chapterSchema.timeSpent}), 0)` })
.from(chapterSchema)
.get();
return { totalTimeSpent: result?.totalTimeSpent ?? 0 };
}
/**
* Get total read chapters count for all novels in library
*/
export const getChaptersReadCountFromDb = async (): Promise<LibraryStats> => {
const result = await dbManager
.select({ chaptersRead: count() })
.from(chapterSchema)
.innerJoin(novelSchema, eq(chapterSchema.novelId, novelSchema.id))
.where(
and(eq(novelSchema.inLibrary, true), eq(chapterSchema.unread, false)),
)
.get();
return result ?? { chaptersRead: 0 };
};
/**
* Get total unread chapters count for all novels in library
*/
export const getChaptersUnreadCountFromDb = async (): Promise<LibraryStats> => {
const result = await dbManager
.select({ chaptersUnread: count() })
.from(chapterSchema)
.innerJoin(novelSchema, eq(chapterSchema.novelId, novelSchema.id))
.where(and(eq(novelSchema.inLibrary, true), eq(chapterSchema.unread, true)))
.get();
return result ?? { chaptersUnread: 0 };
};
/**
* Get total downloaded chapters count for all novels in library
*/
export const getChaptersDownloadedCountFromDb =
export const getTopNovelsByTimeSpentFromDb =
async (): Promise<LibraryStats> => {
const result = await dbManager
.select({ chaptersDownloaded: count() })
.from(chapterSchema)
.innerJoin(novelSchema, eq(chapterSchema.novelId, novelSchema.id))
.where(
and(
eq(novelSchema.inLibrary, true),
eq(chapterSchema.isDownloaded, true),
),
)
.get();
.select({
id: novelSchema.id,
pluginId: novelSchema.pluginId,
cover: novelSchema.cover,
name: novelSchema.name,
timeSpent: sql<number>`SUM(${chapterSchema.timeSpent})`,
})
.from(novelSchema)
.innerJoin(chapterSchema, eq(chapterSchema.novelId, novelSchema.id))
.where(eq(novelSchema.inLibrary, true))
.groupBy(novelSchema.id)
.orderBy(sql`SUM(${chapterSchema.timeSpent}) DESC`)
.having(sql`SUM(${chapterSchema.timeSpent}) > 0`)
.limit(10)
.all();
return result ?? { chaptersDownloaded: 0 };
return { topNovelsByTimeSpent: result };
};
/**
* Get genre distribution for all novels in library
*/
export const getNovelGenresFromDb = async (): Promise<LibraryStats> => {
/** Get top categories based on time spent */
export const getTopCategoriesByTimeSpentFromDb =
async (): Promise<LibraryStats> => {
const result = await dbManager
.select({
id: categorySchema.id,
name: categorySchema.name,
timeSpent: sql<number>`SUM(${chapterSchema.timeSpent})`,
})
.from(categorySchema)
.innerJoin(
novelCategorySchema,
eq(categorySchema.id, novelCategorySchema.categoryId),
)
.innerJoin(novelSchema, eq(novelSchema.id, novelCategorySchema.novelId))
.innerJoin(chapterSchema, eq(novelSchema.id, chapterSchema.novelId))
.groupBy(categorySchema.id)
.orderBy(sql`SUM(${chapterSchema.timeSpent}) DESC`)
.having(sql`SUM(${chapterSchema.timeSpent}) > 0`)
.limit(10)
.all();
return { topCategoriesByTimeSpent: result };
};
export interface NovelWithGenres {
id: number;
name: string;
path: string;
cover: string | null;
pluginId: string;
genres: string | null;
status: string | null;
}
export const getNovelsWithGenresFromDb = async (): Promise<
NovelWithGenres[]
> => {
const res = await dbManager
.select({ genres: novelSchema.genres })
.select({
id: novelSchema.id,
path: novelSchema.path,
name: novelSchema.name,
cover: novelSchema.cover,
pluginId: novelSchema.pluginId,
genres: novelSchema.genres,
status: novelSchema.status,
})
.from(novelSchema)
.where(eq(novelSchema.inLibrary, true))
.all();
const genres: string[] = [];
res.forEach(item => {
const novelGenres = item.genres?.split(/\s*,\s*/);
if (novelGenres?.length) {
genres.push(...novelGenres);
}
});
return { genres: countBy(genres) };
return res;
};
/**
* Get status distribution for all novels in library
* Get aggregate library stats from Novel table pre-computed columns.
* Single query replaces: getLibraryStatsFromDb, getChaptersTotalCountFromDb,
* getChaptersReadCountFromDb, getChaptersUnreadCountFromDb,
* getChaptersDownloadedCountFromDb, getTotalTimeSpentFromDb.
*/
export const getNovelStatusFromDb = async (): Promise<LibraryStats> => {
const res = await dbManager
.select({ status: novelSchema.status })
export const getAggregateStatsFromDb = async (): Promise<LibraryStats> => {
const result = await dbManager
.select({
novelsCount: count(),
sourcesCount: sql<number>`COUNT(DISTINCT ${novelSchema.pluginId})`,
chaptersCount: sql<number>`COALESCE(SUM(${novelSchema.totalChapters}), 0)`,
chaptersDownloaded: sql<number>`COALESCE(SUM(${novelSchema.chaptersDownloaded}), 0)`,
chaptersUnread: sql<number>`COALESCE(SUM(${novelSchema.chaptersUnread}), 0)`,
totalTimeSpent: sql<number>`COALESCE((SELECT SUM(${chapterSchema.timeSpent}) FROM ${chapterSchema} ch INNER JOIN ${novelSchema} n ON ch.novelId = n.id WHERE n.inLibrary = true), 0)`,
})
.from(novelSchema)
.where(eq(novelSchema.inLibrary, true))
.all();
.get();
const statusList: string[] = [];
res.forEach(item => {
const novelStatus = item.status?.split(/\s*,\s*/);
if (novelStatus?.length) {
statusList.push(...novelStatus);
}
});
return { status: countBy(statusList) };
return {
novelsCount: result?.novelsCount ?? 0,
sourcesCount: result?.sourcesCount ?? 0,
chaptersCount: result?.chaptersCount ?? 0,
chaptersRead: (result?.chaptersCount ?? 0) - (result?.chaptersUnread ?? 0),
chaptersUnread: result?.chaptersUnread ?? 0,
chaptersDownloaded: result?.chaptersDownloaded ?? 0,
totalTimeSpent: result?.totalTimeSpent ?? 0,
};
};
@@ -6,17 +6,16 @@
import './mockDb';
import { setupTestDatabase, getTestDb, teardownTestDatabase } from './setup';
import { insertTestNovel, insertTestChapter, clearAllTables, insertTestNovelCategory, insertTestCategory } from './testData';
import {
insertTestNovel,
insertTestChapter,
clearAllTables,
insertTestNovelCategory,
insertTestCategory,
} from './testData';
import {
getLibraryStatsFromDb,
getChaptersTotalCountFromDb,
getChaptersReadCountFromDb,
getChaptersUnreadCountFromDb,
getChaptersDownloadedCountFromDb,
getNovelGenresFromDb,
getNovelStatusFromDb,
getTotalTimeSpentFromDb,
getAggregateStatsFromDb,
getTopCategoriesByTimeSpentFromDb,
getTopNovelsByTimeSpentFromDb,
} from '../StatsQueries';
@@ -31,41 +30,68 @@ describe('StatsQueries', () => {
teardownTestDatabase();
});
describe('getLibraryStatsFromDb', () => {
it('should return correct novel and source counts', async () => {
describe('getAggregateStatsFromDb', () => {
it('should return counts, totals, and chapter stats from Novel pre-computed columns', async () => {
const testDb = getTestDb();
// Insert novels from different sources
await insertTestNovel(testDb, {
const novelId1 = await insertTestNovel(testDb, {
inLibrary: true,
pluginId: 'source1',
totalChapters: 10,
chaptersDownloaded: 3,
chaptersUnread: 2,
});
await insertTestNovel(testDb, {
inLibrary: true,
pluginId: 'source1',
});
await insertTestNovel(testDb, {
const novelId2 = await insertTestNovel(testDb, {
inLibrary: true,
pluginId: 'source2',
});
await insertTestNovel(testDb, {
inLibrary: false, // Not in library
totalChapters: 5,
chaptersDownloaded: 1,
chaptersUnread: 5,
});
const result = await getLibraryStatsFromDb();
// Add chapters with timeSpent to test totalTimeSpent
await insertTestChapter(testDb, novelId1, { timeSpent: 100 });
await insertTestChapter(testDb, novelId1, { timeSpent: 200 });
await insertTestChapter(testDb, novelId2, { timeSpent: 300 });
expect(result.novelsCount).toBe(3);
// Novel not in library should be ignored
const nonLibNovelId = await insertTestNovel(testDb, {
inLibrary: false,
pluginId: 'source3',
totalChapters: 99,
chaptersDownloaded: 99,
chaptersUnread: 99,
});
await insertTestChapter(testDb, nonLibNovelId, { timeSpent: 999 });
const result = await getAggregateStatsFromDb();
// Triggers auto-increment totalChapters (1 per chapter insert) and chaptersUnread (1 per unread chapter insert)
// novelId1: totalChapters 10 -> 12 (2 chapters inserted), chaptersUnread 2 -> 4
// novelId2: totalChapters 5 -> 6 (1 chapter inserted), chaptersUnread 5 -> 6
expect(result.novelsCount).toBe(2);
expect(result.sourcesCount).toBe(2);
expect(result.chaptersCount).toBe(18); // (10+2) + (5+1) = 18
expect(result.chaptersDownloaded).toBe(4); // 3 + 1, trigger doesn't add (default isDownloaded=false)
expect(result.chaptersUnread).toBe(10); // (2+2) + (5+1) = 10
expect(result.chaptersRead).toBe(8); // 18 - 10 = 8
expect(result.totalTimeSpent).toBe(600); // 100 + 200 + 300
});
it('should return zero counts when library is empty', async () => {
const result = await getLibraryStatsFromDb();
it('should return zeros when library is empty', async () => {
const result = await getAggregateStatsFromDb();
expect(result.novelsCount).toBe(0);
expect(result.sourcesCount).toBe(0);
expect(result.chaptersCount).toBe(0);
expect(result.chaptersDownloaded).toBe(0);
expect(result.chaptersUnread).toBe(0);
expect(result.chaptersRead).toBe(0);
expect(result.totalTimeSpent).toBe(0);
});
it('should only count novels in library', async () => {
it('should only count in-library novels', async () => {
const testDb = getTestDb();
await insertTestNovel(testDb, { inLibrary: true });
@@ -73,259 +99,11 @@ describe('StatsQueries', () => {
await insertTestNovel(testDb, { inLibrary: false });
await insertTestNovel(testDb, { inLibrary: false });
const result = await getLibraryStatsFromDb();
const result = await getAggregateStatsFromDb();
expect(result.novelsCount).toBe(2);
});
});
describe('getChaptersTotalCountFromDb', () => {
it('should return correct total chapter count', async () => {
const testDb = getTestDb();
const novelId1 = await insertTestNovel(testDb, { inLibrary: true });
const novelId2 = await insertTestNovel(testDb, { inLibrary: true });
await insertTestNovel(testDb, { inLibrary: false }); // Not in library
await insertTestChapter(testDb, novelId1);
await insertTestChapter(testDb, novelId1);
await insertTestChapter(testDb, novelId2);
await insertTestChapter(testDb, novelId2);
await insertTestChapter(testDb, novelId2);
const result = await getChaptersTotalCountFromDb();
expect(result.chaptersCount).toBe(5);
});
it('should return zero when no chapters in library novels', async () => {
const testDb = getTestDb();
await insertTestNovel(testDb, { inLibrary: true });
const result = await getChaptersTotalCountFromDb();
expect(result.chaptersCount).toBe(0);
});
it('should only count chapters from library novels', async () => {
const testDb = getTestDb();
const libraryNovelId = await insertTestNovel(testDb, { inLibrary: true });
const nonLibraryNovelId = await insertTestNovel(testDb, {
inLibrary: false,
});
await insertTestChapter(testDb, libraryNovelId);
await insertTestChapter(testDb, libraryNovelId);
await insertTestChapter(testDb, nonLibraryNovelId);
const result = await getChaptersTotalCountFromDb();
expect(result.chaptersCount).toBe(2);
});
});
describe('getChaptersReadCountFromDb', () => {
it('should return correct count of read chapters', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
await insertTestChapter(testDb, novelId, { unread: false }); // Read
await insertTestChapter(testDb, novelId, { unread: false }); // Read
await insertTestChapter(testDb, novelId, { unread: true }); // Unread
const result = await getChaptersReadCountFromDb();
expect(result.chaptersRead).toBe(2);
});
it('should return zero when no chapters are read', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
await insertTestChapter(testDb, novelId, { unread: true });
const result = await getChaptersReadCountFromDb();
expect(result.chaptersRead).toBe(0);
});
it('should only count chapters from library novels', async () => {
const testDb = getTestDb();
const libraryNovelId = await insertTestNovel(testDb, { inLibrary: true });
const nonLibraryNovelId = await insertTestNovel(testDb, {
inLibrary: false,
});
await insertTestChapter(testDb, libraryNovelId, { unread: false });
await insertTestChapter(testDb, nonLibraryNovelId, { unread: false });
const result = await getChaptersReadCountFromDb();
expect(result.chaptersRead).toBe(1);
});
});
describe('getChaptersUnreadCountFromDb', () => {
it('should return correct count of unread chapters', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
await insertTestChapter(testDb, novelId, { unread: true }); // Unread
await insertTestChapter(testDb, novelId, { unread: true }); // Unread
await insertTestChapter(testDb, novelId, { unread: false }); // Read
const result = await getChaptersUnreadCountFromDb();
expect(result.chaptersUnread).toBe(2);
});
it('should return zero when all chapters are read', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
await insertTestChapter(testDb, novelId, { unread: false });
const result = await getChaptersUnreadCountFromDb();
expect(result.chaptersUnread).toBe(0);
});
});
describe('getChaptersDownloadedCountFromDb', () => {
it('should return correct count of downloaded chapters', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
await insertTestChapter(testDb, novelId, { isDownloaded: true });
await insertTestChapter(testDb, novelId, { isDownloaded: true });
await insertTestChapter(testDb, novelId, { isDownloaded: false });
const result = await getChaptersDownloadedCountFromDb();
expect(result.chaptersDownloaded).toBe(2);
});
it('should return zero when no chapters are downloaded', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
await insertTestChapter(testDb, novelId, { isDownloaded: false });
const result = await getChaptersDownloadedCountFromDb();
expect(result.chaptersDownloaded).toBe(0);
});
});
describe('getNovelGenresFromDb', () => {
it('should return correct genre distribution', async () => {
const testDb = getTestDb();
await insertTestNovel(testDb, {
inLibrary: true,
genres: 'Fantasy, Adventure',
});
await insertTestNovel(testDb, {
inLibrary: true,
genres: 'Fantasy, Romance',
});
await insertTestNovel(testDb, {
inLibrary: true,
genres: 'Sci-Fi',
});
await insertTestNovel(testDb, {
inLibrary: false,
genres: 'Horror', // Not in library
});
const result = await getNovelGenresFromDb();
expect(result.genres).toEqual({
Fantasy: 2,
Adventure: 1,
Romance: 1,
'Sci-Fi': 1,
});
});
it('should return empty genres when no novels in library', async () => {
const result = await getNovelGenresFromDb();
expect(result.genres).toEqual({});
});
it('should handle novels with null genres', async () => {
const testDb = getTestDb();
await insertTestNovel(testDb, { inLibrary: true, genres: null });
await insertTestNovel(testDb, { inLibrary: true, genres: 'Fantasy' });
const result = await getNovelGenresFromDb();
expect(result.genres).toEqual({ Fantasy: 1 });
});
it('should handle genres with extra whitespace', async () => {
const testDb = getTestDb();
await insertTestNovel(testDb, {
inLibrary: true,
genres: 'Fantasy, Adventure, Action',
});
const result = await getNovelGenresFromDb();
expect(result.genres).toHaveProperty('Fantasy');
expect(result.genres).toHaveProperty('Adventure');
expect(result.genres).toHaveProperty('Action');
});
});
describe('getNovelStatusFromDb', () => {
it('should return correct status distribution', async () => {
const testDb = getTestDb();
await insertTestNovel(testDb, { inLibrary: true, status: 'Ongoing' });
await insertTestNovel(testDb, { inLibrary: true, status: 'Ongoing' });
await insertTestNovel(testDb, { inLibrary: true, status: 'Completed' });
await insertTestNovel(testDb, { inLibrary: true, status: 'Hiatus' });
await insertTestNovel(testDb, {
inLibrary: false,
status: 'Dropped', // Not in library
});
const result = await getNovelStatusFromDb();
expect(result.status).toEqual({
Ongoing: 2,
Completed: 1,
Hiatus: 1,
});
});
it('should return empty status when no novels in library', async () => {
const result = await getNovelStatusFromDb();
expect(result.status).toEqual({});
});
it('should handle novels with null status', async () => {
const testDb = getTestDb();
await insertTestNovel(testDb, { inLibrary: true, status: null });
await insertTestNovel(testDb, { inLibrary: true, status: 'Ongoing' });
const result = await getNovelStatusFromDb();
expect(result.status).toEqual({ Ongoing: 1 });
});
});
describe('getTopNovelsByTimeSpentFromDb', () => {
it('should return top novels ordered by total time spent descending', async () => {
const testDb = getTestDb();
@@ -362,7 +140,9 @@ describe('StatsQueries', () => {
const testDb = getTestDb();
const libraryNovelId = await insertTestNovel(testDb, { inLibrary: true });
const nonLibraryNovelId = await insertTestNovel(testDb, { inLibrary: false });
const nonLibraryNovelId = await insertTestNovel(testDb, {
inLibrary: false,
});
await insertTestChapter(testDb, libraryNovelId, { timeSpent: 150 });
await insertTestChapter(testDb, nonLibraryNovelId, { timeSpent: 999 });
@@ -464,26 +244,4 @@ describe('StatsQueries', () => {
expect(result.topCategoriesByTimeSpent).toEqual([]);
});
});
describe('getTotalTimeSpentFromDb', () => {
it('should sum timeSpent across all chapters', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
await insertTestChapter(testDb, novelId, { timeSpent: 120 });
await insertTestChapter(testDb, novelId, { timeSpent: 180 });
await insertTestChapter(testDb, novelId, { timeSpent: 50 });
const result = await getTotalTimeSpentFromDb();
expect(result.totalTimeSpent).toBe(350);
});
it('should return 0 when there are no chapters or total is null/zero', async () => {
const result = await getTotalTimeSpentFromDb();
expect(result.totalTimeSpent).toBe(0);
});
});
});
+13
View File
@@ -0,0 +1,13 @@
import { useMMKVObject } from 'react-native-mmkv';
import { GENRE_TAXONOMY, TaxonomyNode } from '@screens/GenreStatsScreen/taxonomy';
const TAXONOMY_KEY = 'GENRE_TAXONOMY';
export const useGenreTaxonomy = () => {
const [taxonomy = GENRE_TAXONOMY, setTaxonomyRaw] =
useMMKVObject<TaxonomyNode[]>(TAXONOMY_KEY);
const setTaxonomy = (value: TaxonomyNode[]) => setTaxonomyRaw(value);
return { taxonomy, setTaxonomy } as const;
};
+42 -1
View File
@@ -700,6 +700,7 @@
"statsScreen": {
"downloadedChapters": "Downloaded chapters",
"genreDistribution": "Genre distribution",
"novels": "novels",
"readChapters": "Read chapters",
"sources": "Sources",
"statusDistribution": "Status distribution",
@@ -711,7 +712,9 @@
"topCategoriesByTimeSpent": "Top categories by time spent",
"totalTimeSpent": "Total time spent",
"showNovels": "Group by novel",
"showCategories": "Group by category"
"showCategories": "Group by category",
"plugins": "Plugins",
"pluginDistribution": "Plugin distribution"
},
"tracking": "Tracking",
"trackingScreen": {
@@ -774,5 +777,43 @@
"one": "%{count} day",
"other": "%{count} days"
}
},
"genreStats": {
"title": "Genre statistics",
"totalNovels": {
"one": "%{count} novel",
"other": "%{count} novels"
},
"topLevelCategories": {
"one": "%{count} category",
"other": "%{count} categories"
},
"mostCommon": "Most common",
"empty": "No genres found",
"seeAll": "View all genres",
"subgenres": {
"one": "%{count} subgenre",
"other": "%{count} subgenres"
},
"novelsIn": "Novels in %{genre}",
"noNovels": "No novels found for this category",
"seeAllNovels": "See all",
"editTaxonomy": "Edit genre taxonomy",
"taxonomyTitle": "Genre taxonomy",
"parentCategories": "Parent categories",
"normalization": "Normalization rules",
"addCategory": "Add category",
"addChild": "Add subgenre",
"addNormalization": "Add rule",
"deleteConfirmTitle": "Delete entry",
"deleteCategoryConfirm": "Delete this category and its subgenres?",
"deleteChildConfirm": "Delete this subgenre?",
"deleteNormalizationConfirm": "Delete this normalization rule?",
"parentNamePlaceholder": "Category name",
"childNamePlaceholder": "Add Subgenre",
"normalizationKeyPlaceholder": "e.g. sci-fi",
"normalizationValuePlaceholder": "e.g. Sci-Fi",
"noCategories": "No parent categories configured",
"noNormalization": "No normalization rules configured"
}
}
+33
View File
@@ -591,6 +591,7 @@ export interface StringMap {
'sourceScreen.noResultsFound': 'string';
'statsScreen.downloadedChapters': 'string';
'statsScreen.genreDistribution': 'string';
'statsScreen.novels': 'string';
'statsScreen.readChapters': 'string';
'statsScreen.sources': 'string';
'statsScreen.statusDistribution': 'string';
@@ -603,6 +604,8 @@ export interface StringMap {
'statsScreen.totalTimeSpent': 'string';
'statsScreen.showNovels': 'string';
'statsScreen.showCategories': 'string';
'statsScreen.plugins': 'string';
'statsScreen.pluginDistribution': 'string';
'tracking': 'string';
'trackingScreen.info': 'string';
'trackingScreen.logOutMessage': 'string';
@@ -647,4 +650,34 @@ export interface StringMap {
'time.hours.other': 'string';
'time.days.one': 'string';
'time.days.other': 'string';
'genreStats.title': 'string';
'genreStats.totalNovels.one': 'string';
'genreStats.totalNovels.other': 'string';
'genreStats.topLevelCategories.one': 'string';
'genreStats.topLevelCategories.other': 'string';
'genreStats.mostCommon': 'string';
'genreStats.empty': 'string';
'genreStats.seeAll': 'string';
'genreStats.subgenres.one': 'string';
'genreStats.subgenres.other': 'string';
'genreStats.novelsIn': 'string';
'genreStats.noNovels': 'string';
'genreStats.seeAllNovels': 'string';
'genreStats.editTaxonomy': 'string';
'genreStats.taxonomyTitle': 'string';
'genreStats.parentCategories': 'string';
'genreStats.normalization': 'string';
'genreStats.addCategory': 'string';
'genreStats.addChild': 'string';
'genreStats.addNormalization': 'string';
'genreStats.deleteConfirmTitle': 'string';
'genreStats.deleteCategoryConfirm': 'string';
'genreStats.deleteChildConfirm': 'string';
'genreStats.deleteNormalizationConfirm': 'string';
'genreStats.parentNamePlaceholder': 'string';
'genreStats.childNamePlaceholder': 'string';
'genreStats.normalizationKeyPlaceholder': 'string';
'genreStats.normalizationValuePlaceholder': 'string';
'genreStats.noCategories': 'string';
'genreStats.noNormalization': 'string';
}
+2
View File
@@ -15,6 +15,7 @@ import CategoriesScreen from '@screens/Categories/CategoriesScreen';
import RespositorySettings from '@screens/settings/SettingsRepositoryScreen/SettingsRepositoryScreen';
import LibrarySettings from '@screens/settings/SettingsLibraryScreen/SettingsLibraryScreen';
import StatsScreen from '@screens/StatsScreen/StatsScreen';
import GenreTaxonomyScreen from '@screens/settings/SettingsTaxonomyScreen/SettingsTaxonomyScreen';
import { MoreStackParamList, SettingsStackParamList } from './types';
import { useTheme } from '@hooks/persisted';
@@ -45,6 +46,7 @@ const SettingsStack = () => {
component={RespositorySettings}
/>
<Stack.Screen name="LibrarySettings" component={LibrarySettings} />
<Stack.Screen name="GenreTaxonomy" component={GenreTaxonomyScreen} />
</Stack.Navigator>
);
};
+14 -9
View File
@@ -83,6 +83,7 @@ export type SettingsStackParamList = {
AdvancedSettings: undefined;
LibrarySettings: undefined;
RespositorySettings: { url?: string } | undefined;
GenreTaxonomy: undefined;
};
export type NovelScreenProps = StackScreenProps<
@@ -95,14 +96,14 @@ export type ChapterScreenProps = StackScreenProps<
>;
export type ReaderStackParamList = {
Novel:
| {
name: string;
path: string;
pluginId: string;
cover: string | null;
isLocal?: boolean | null;
}
| Omit<NovelInfo, 'id'>;
| {
name: string;
path: string;
pluginId: string;
cover: string | null;
isLocal?: boolean | null;
}
| Omit<NovelInfo, 'id'>;
Chapter: {
novel: NovelInfo;
chapter: ChapterInfo;
@@ -182,6 +183,10 @@ export type LibrarySettingsScreenProps = CompositeScreenProps<
StackScreenProps<SettingsStackParamList, 'LibrarySettings'>,
StackScreenProps<MoreStackParamList, 'SettingsStack'>
>;
export type GenreTaxonomyScreenProps = CompositeScreenProps<
StackScreenProps<SettingsStackParamList, 'GenreTaxonomy'>,
StackScreenProps<MoreStackParamList, 'SettingsStack'>
>;
export type RespositorySettingsScreenProps = CompositeScreenProps<
StackScreenProps<SettingsStackParamList, 'RespositorySettings'>,
@@ -191,6 +196,6 @@ export type RespositorySettingsScreenProps = CompositeScreenProps<
declare global {
namespace ReactNavigation {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface RootParamList extends RootStackParamList {}
interface RootParamList extends RootStackParamList { }
}
}
@@ -0,0 +1,102 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View, ViewStyle } from 'react-native';
import type { ThemeColors } from '@theme/types';
interface GenreRowProps {
name: string;
count: number;
maxCount: number; // global max across all genres
theme: ThemeColors;
isChild?: boolean; // indented, smaller font if true
showBar?: boolean; // default true; set false for parent header rows
onPress?: () => void;
}
const GenreRow: React.FC<GenreRowProps> = ({
name,
count,
maxCount,
theme,
isChild = false,
showBar = true,
onPress,
}) => {
const barWidth =
count > 0 ? (maxCount > 0 ? (count / maxCount) * 100 : 0) : 0;
const barStyle: ViewStyle = {
width: `${Math.max(barWidth, count > 0 ? 2 : 0)}%`,
backgroundColor: theme.primary,
height: '100%',
borderRadius: 6,
};
const row = (
<View style={[styles.row, isChild && styles.childRow]}>
<Text
style={[
styles.label,
{
color: theme.onSurface,
fontWeight: isChild ? '400' : '600',
},
]}
numberOfLines={2}
>
{name}
</Text>
{showBar && (
<View
style={[styles.barTrack, { backgroundColor: theme.surfaceVariant }]}
>
<View style={barStyle} />
</View>
)}
<Text style={[styles.count, { color: theme.onSurfaceVariant }]}>
{count}
</Text>
</View>
);
if (onPress) {
return (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={`${name}, ${count}`}
>
{row}
</Pressable>
);
}
return row;
};
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 6,
},
childRow: {
paddingLeft: 16,
},
label: {
flex: 1,
fontSize: 14,
},
barTrack: {
flex: 2,
height: 12,
borderRadius: 6,
marginHorizontal: 8,
},
count: {
width: 40,
textAlign: 'right',
fontSize: 14,
},
});
export default GenreRow;
@@ -0,0 +1,161 @@
import React from 'react';
import {
useAdaptiveRender,
useRecyclingState,
} from '@legendapp/list/react-native';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
import { getString } from '@i18n/translations';
import GenreRow from './GenreRow';
import NovelCarousel from './NovelCarousel';
import AnimatedHeight from '@screens/StatsScreen/components/AnimatedHeight';
import type { ThemeColors } from '@theme/types';
import type { GenreTreeNode } from '../utils';
import type { NovelWithGenres } from '@database/queries/StatsQueries';
interface GenreSectionProps {
node: GenreTreeNode; // the category node
globalMax: number; // global bar scale max
novels: NovelWithGenres[]; // ALL novels (filtered internally)
theme: ThemeColors;
onNovelPress: (novel: {
id: number;
name: string;
path: string;
cover: string | null;
pluginId: string;
}) => void;
}
const GenreSection: React.FC<GenreSectionProps> = ({
node,
globalMax,
novels,
theme,
onNovelPress,
}) => {
const [expanded, setExpanded] = useRecyclingState(false);
const adaptiveRender = useAdaptiveRender();
const hasChildren = node.children && node.children.length > 0;
const categoryNovels = React.useMemo(
() =>
novels
.filter(n => node.novelIds.includes(n.id))
.sort((a, b) => a.name.localeCompare(b.name)),
[novels, node.novelIds],
);
const toggleExpand = () => setExpanded(e => !e);
const accessibilityLabel = expanded
? `${node.name}, ${node.categoryTotal} novels, expanded`
: `${node.name}, ${node.categoryTotal} novels, collapsed`;
return (
<View style={styles.container}>
<Pressable
onPress={toggleExpand}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
style={({ pressed }) => [
styles.headerRow,
{ borderBottomColor: theme.outlineVariant },
pressed && { opacity: 0.7 },
]}
>
<View style={styles.headerLeft}>
<Text
style={[styles.headerName, { color: theme.onSurface }]}
numberOfLines={2}
>
{node.name}
</Text>
{!expanded && hasChildren && (
<Text style={[styles.subtitle, { color: theme.onSurfaceVariant }]}>
{getString('genreStats.subgenres', {
count: node.children!.length,
})}
</Text>
)}
</View>
<View style={styles.headerRight}>
<Text style={[styles.headerCount, { color: theme.onSurfaceVariant }]}>
{node.categoryTotal}
</Text>
<MaterialCommunityIcons
name={expanded ? 'chevron-down' : 'chevron-right'}
color={theme.onSurfaceVariant}
size={24}
/>
</View>
</Pressable>
{adaptiveRender === 'light' ? null : (
<AnimatedHeight key={node.name + '-colapse'} expanded={expanded}>
{hasChildren &&
node.children!.map(child => (
<GenreRow
key={child.name}
name={child.name}
count={child.count}
maxCount={globalMax}
theme={theme}
isChild
/>
))}
{hasChildren && <View style={styles.separator} />}
<NovelCarousel
novels={categoryNovels}
genreName={node.name}
theme={theme}
onNovelPress={onNovelPress}
/>
<View style={{ height: 20 }} />
</AnimatedHeight>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: 4,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: 1,
},
headerLeft: {
flex: 1,
flexDirection: 'row',
alignItems: 'baseline',
gap: 8,
},
headerName: {
fontWeight: '600',
fontSize: 16,
},
subtitle: {
fontSize: 12,
},
headerRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
headerCount: {
fontSize: 16,
},
expandedContent: {
paddingTop: 8,
},
separator: {
height: 8,
},
});
export default GenreSection;
@@ -0,0 +1,57 @@
import React from 'react';
import { Pressable, StyleSheet, Text } from 'react-native';
import NovelCoverImage from '@components/NovelCoverImage';
import type { ThemeColors } from '@theme/types';
interface NovelCardProps {
novel: { id: number; name: string; cover: string | null; pluginId: string };
theme: ThemeColors;
onPress: () => void;
}
const NovelCard: React.FC<NovelCardProps> = React.memo(
({ novel, theme, onPress }) => {
return (
<Pressable
onPress={onPress}
accessibilityLabel={`${novel.name}, novel`}
accessibilityRole="button"
style={styles.card}
>
<NovelCoverImage
uri={novel.cover}
theme={theme}
iconSize={20}
style={styles.cover}
contentFit="cover"
/>
<Text
style={[styles.title, { color: theme.onSurface }]}
numberOfLines={1}
>
{novel.name}
</Text>
</Pressable>
);
},
(prev, next) => prev.novel.id === next.novel.id,
);
const styles = StyleSheet.create({
card: {
width: 80,
marginRight: 8,
},
cover: {
width: 80,
aspectRatio: 2 / 3,
borderRadius: 4,
},
title: {
fontSize: 12,
textAlign: 'center',
marginTop: 4,
},
});
export default NovelCard;
@@ -0,0 +1,125 @@
import React, { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
import { getString } from '@i18n/translations';
import NovelCard from './NovelCard';
import type { ThemeColors } from '@theme/types';
import { FlatList } from 'react-native-gesture-handler';
interface NovelCarouselProps {
novels: {
id: number;
name: string;
path: string;
cover: string | null;
pluginId: string;
}[];
genreName: string;
theme: ThemeColors;
onNovelPress: (novel: {
id: number;
name: string;
path: string;
cover: string | null;
pluginId: string;
}) => void;
}
const MAX_VISIBLE = 10;
const NovelCarousel: React.FC<NovelCarouselProps> = ({
novels,
genreName,
theme,
onNovelPress,
}) => {
const [showAll, setShowAll] = useState(false);
if (novels.length === 0) {
return (
<Text style={[styles.emptyText, { color: theme.onSurfaceVariant }]}>
{getString('genreStats.noNovels')}
</Text>
);
}
const visibleNovels = showAll ? novels : novels.slice(0, MAX_VISIBLE);
const hasMore = !showAll && novels.length > MAX_VISIBLE;
const data = hasMore
? [...visibleNovels, { id: -1, name: '', cover: null, pluginId: '' } as any]
: visibleNovels;
return (
<View style={styles.container}>
<Text style={[styles.heading, { color: theme.onSurfaceVariant }]}>
{getString('genreStats.novelsIn', { genre: genreName })}
</Text>
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={data}
keyExtractor={item =>
item.id != null && item.id !== -1 ? String(item.id) : 'see-all'
}
renderItem={({ item }) => {
if (item.id === -1) {
return (
<Pressable
onPress={() => setShowAll(true)}
accessibilityRole="button"
accessibilityLabel={getString('genreStats.seeAllNovels')}
style={styles.seeAllCard}
>
<Text style={[styles.seeAllText, { color: theme.primary }]}>
{getString('genreStats.seeAllNovels')}
</Text>
<MaterialCommunityIcons
name="chevron-right"
color={theme.primary}
size={20}
/>
</Pressable>
);
}
return (
<NovelCard
novel={item}
theme={theme}
onPress={() => onNovelPress(item)}
/>
);
}}
windowSize={5}
maxToRenderPerBatch={10}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginTop: 8,
},
heading: {
fontSize: 14,
marginBottom: 8,
},
emptyText: {
fontSize: 13,
padding: 8,
},
seeAllCard: {
width: 80,
marginRight: 8,
justifyContent: 'center',
alignItems: 'center',
},
seeAllText: {
fontSize: 12,
fontWeight: '500',
textAlign: 'center',
},
});
export default NovelCarousel;
@@ -0,0 +1,4 @@
export { default as GenreRow } from './GenreRow';
export { default as NovelCard } from './NovelCard';
export { default as NovelCarousel } from './NovelCarousel';
export { default as GenreSection } from './GenreSection';
+22
View File
@@ -0,0 +1,22 @@
/** Explicit parent → children relationships. A genre listed as a parent MUST also be listed
* in knownRoots so it is recognized as a top-level category. */
export interface TaxonomyNode {
parent: string;
children: string[];
}
export const GENRE_TAXONOMY: TaxonomyNode[] = [
{
parent: 'Fantasy',
children: ['High Fantasy', 'Urban Fantasy', 'Modern Fantasy', 'Portal Fantasy / Isekai'],
},
{
parent: 'Lead',
children: ['Female Lead', 'Strong Lead', 'Anti-Hero Lead', 'Non-Human Lead', 'Villainous Lead', 'Male Lead'],
},
{
parent: 'Romance',
children: ['Lesbian Romance', 'Romance Subplot'],
},
];
+126
View File
@@ -0,0 +1,126 @@
import { NovelWithGenres } from '@database/queries/StatsQueries';
import { GENRE_TAXONOMY, TaxonomyNode } from './taxonomy';
export interface GenreTreeNode {
name: string;
count: number;
categoryTotal: number;
novelIds: number[];
children?: GenreTreeNode[];
isCategory: boolean;
}
export function normalizeGenre(genre: string): string {
const trimmed = genre.trim();
if (!trimmed) return 'Unknown';
// Strip non-alphanumeric chars, lower-case, then capitalize first letter.
// This makes "sci-fi", "sci fi", "scifi", "SCIFI" all produce "Scifi".
const cleaned = trimmed.toLowerCase().replace(/[^a-zA-Z0-9]/g, '');
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
export function buildGenreTree(
novels: NovelWithGenres[],
taxonomy?: TaxonomyNode[],
): GenreTreeNode[] {
// Step 1: For each novel, split genres by comma, normalize, collect into Map<genreName, Set<novelId>>
const genreMap = new Map<string, Set<number>>();
for (const novel of novels) {
if (!novel.genres || novel.genres.trim().length === 0) {
const unknown = 'Unknown';
if (!genreMap.has(unknown)) genreMap.set(unknown, new Set());
genreMap.get(unknown)!.add(novel.id);
continue;
}
const parts = novel.genres.split(/\s*,\s*/);
for (const part of parts) {
const normalized = normalizeGenre(part);
if (!genreMap.has(normalized)) genreMap.set(normalized, new Set());
genreMap.get(normalized)!.add(novel.id);
}
}
const processed = new Set<string>();
const result: GenreTreeNode[] = [];
// Step 2: Build taxonomy-based categories (normalize names for matching)
const activeTaxonomy = taxonomy ?? GENRE_TAXONOMY;
for (const taxNode of activeTaxonomy) {
const normalizedParent = normalizeGenre(taxNode.parent);
const parentEntry = genreMap.get(normalizedParent);
if (!parentEntry) continue;
// Collect novelIds: union of parent + all children that exist in genreMap
const categoryNovelIds = new Set(parentEntry);
const children: GenreTreeNode[] = [];
for (const childName of taxNode.children) {
const normalizedChild = normalizeGenre(childName);
const childNovelIds = genreMap.get(normalizedChild);
if (!childNovelIds) continue;
// Add child's novels to category total
for (const id of childNovelIds) {
categoryNovelIds.add(id);
}
processed.add(normalizedChild);
children.push({
name: childName,
count: childNovelIds.size,
categoryTotal: childNovelIds.size,
novelIds: Array.from(childNovelIds),
isCategory: false,
});
}
processed.add(normalizedParent);
// Sort children by count descending
children.sort((a, b) => b.count - a.count);
result.push({
name: taxNode.parent,
count: parentEntry.size,
categoryTotal: categoryNovelIds.size,
novelIds: Array.from(categoryNovelIds),
children: children.length > 0 ? children : undefined,
isCategory: true,
});
}
// Step 3: Remaining unprocessed genres → standalone
for (const [genreName, novelIds] of genreMap) {
if (processed.has(genreName)) continue;
result.push({
name: genreName,
count: novelIds.size,
categoryTotal: novelIds.size,
novelIds: Array.from(novelIds),
isCategory: true,
});
}
// Step 4: Sort by categoryTotal descending
result.sort((a, b) => b.categoryTotal - a.categoryTotal);
return result;
}
export function getNovelsForGenre(
novels: NovelWithGenres[],
genreIds: Set<number>,
): NovelWithGenres[] {
const novelMap = new Map<number, NovelWithGenres>();
for (const novel of novels) {
if (genreIds.has(novel.id)) {
novelMap.set(novel.id, novel);
}
}
return Array.from(novelMap.values()).sort((a, b) =>
a.name.localeCompare(b.name),
);
}
+154
View File
@@ -0,0 +1,154 @@
import { useCallback, useMemo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { LegendList } from '@legendapp/list/react-native';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
import { NavigationProp } from '@react-navigation/native';
import { getString } from '@i18n/translations';
import { translateNovelStatus } from '@utils/translateEnum';
import {
buildGenreTree,
type GenreTreeNode,
} from '@screens/GenreStatsScreen/utils';
import { useGenreTaxonomy } from '@hooks/persisted/useGenreTaxonomy';
import { GenreSection } from '@screens/GenreStatsScreen/components';
import {
getDonutPalette,
ChapterBar,
DonutChartWithLegend,
} from './components';
import type { ThemeColors } from '@theme/types';
import type { NovelWithGenres } from '@database/queries/StatsQueries';
import type { LibraryStats } from '@database/types';
import type { MoreStackParamList } from '@navigators/types';
interface OverviewTabProps {
allNovels: NovelWithGenres[];
stats: LibraryStats;
theme: ThemeColors;
onNovelPress: (novel: {
id: number;
name: string;
path: string;
cover: string | null;
pluginId: string;
}) => void;
navigation: NavigationProp<MoreStackParamList>;
}
export const OverviewTab: React.FC<OverviewTabProps> = ({
allNovels,
stats,
theme,
onNovelPress,
navigation,
}) => {
const { taxonomy } = useGenreTaxonomy();
const tree = useMemo(
() => buildGenreTree(allNovels, taxonomy),
[allNovels, taxonomy],
);
const globalMax = Math.max(
...tree.flatMap(n => [n.count, ...(n.children?.map(c => c.count) ?? [])]),
1,
);
const statusColors = getDonutPalette(Object.keys(stats.status || {}), theme);
const renderOverviewItem = useCallback(
({ item }: { item: GenreTreeNode }) => (
<GenreSection
node={item}
globalMax={globalMax}
novels={allNovels}
theme={theme}
onNovelPress={onNovelPress}
/>
),
[globalMax, allNovels, theme, onNovelPress],
);
const overviewListHeader = useCallback(
() => (
<View>
<ChapterBar
read={stats.chaptersRead ?? 0}
total={stats.chaptersCount ?? 0}
downloaded={stats.chaptersDownloaded ?? 0}
/>
<DonutChartWithLegend
title={getString('statsScreen.statusDistribution')}
entries={Object.entries(stats.status || {})
.filter(([_, v]) => v > 0)
.map(([k, v]) => ({ key: k, value: v }))}
colors={statusColors}
theme={theme}
centerLabel={getString('statsScreen.novels')}
getLabel={key => translateNovelStatus(key)}
/>
{tree.length > 0 && (
<View style={styles.genreSectionHeader}>
<Text style={[styles.header, { color: theme.onSurfaceVariant }]}>
{getString('statsScreen.genreDistribution')}
</Text>
<Pressable
onPress={() =>
navigation.navigate('SettingsStack', {
screen: 'GenreTaxonomy',
})
}
accessibilityRole="button"
accessibilityLabel={getString('genreStats.editTaxonomy')}
hitSlop={12}
>
<MaterialCommunityIcons
name="cog-outline"
color={theme.onSurfaceVariant}
size={20}
/>
</Pressable>
</View>
)}
</View>
),
[stats, theme, statusColors, tree.length, navigation],
);
return (
<LegendList
style={styles.list}
contentContainerStyle={styles.listContent}
data={tree}
estimatedItemSize={64}
keyExtractor={item => item.name}
getItemType={() => 'genre'}
ListHeaderComponent={overviewListHeader}
renderItem={renderOverviewItem}
recycleItems
showsVerticalScrollIndicator={false}
experimental_adaptiveRender={{}}
/>
);
};
const styles = StyleSheet.create({
list: {
paddingHorizontal: 16,
},
listContent: {
paddingTop: 16,
paddingBottom: 40,
},
genreSectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
header: {
fontWeight: 'bold',
paddingVertical: 16,
},
});
+119
View File
@@ -0,0 +1,119 @@
import { useCallback, useMemo } from 'react';
import { StyleSheet, View } from 'react-native';
import { LegendList } from '@legendapp/list/react-native';
import { getString } from '@i18n/translations';
import { getPlugin } from '@plugins/pluginManager';
import {
getDonutPalette,
DonutChartWithLegend,
PluginSection,
} from './components';
import type { ThemeColors } from '@theme/types';
import type { NovelWithGenres } from '@database/queries/StatsQueries';
interface PluginsTabProps {
allNovels: NovelWithGenres[];
theme: ThemeColors;
onNovelPress: (novel: {
id: number;
name: string;
path: string;
cover: string | null;
pluginId: string;
}) => void;
}
export const PluginsTab: React.FC<PluginsTabProps> = ({
allNovels,
theme,
onNovelPress,
}) => {
const pluginData = useMemo(() => {
const groups = new Map<
string,
{ name: string; count: number; novelIds: number[] }
>();
for (const novel of allNovels) {
const plugin = getPlugin(novel.pluginId);
const name = plugin?.name ?? novel.pluginId;
const group = groups.get(novel.pluginId);
if (group) {
group.novelIds.push(novel.id);
group.count++;
} else {
groups.set(novel.pluginId, { name, count: 1, novelIds: [novel.id] });
}
}
const nodes = Array.from(groups.entries())
.map(([pluginId, data]) => ({
pluginId,
name: data.name,
count: data.count,
}))
.sort((a, b) => b.count - a.count);
const keys = nodes.map(n => n.name);
const colors = getDonutPalette(keys, theme);
const donutEntries = nodes.map(n => ({ key: n.name, value: n.count }));
return { nodes, colors, donutEntries };
}, [allNovels, theme]);
const renderPluginItem = useCallback(
({ item }: { item: { pluginId: string; name: string; count: number } }) => (
<PluginSection
pluginId={item.pluginId}
name={item.name}
count={item.count}
novels={allNovels}
theme={theme}
onNovelPress={onNovelPress}
/>
),
[allNovels, theme, onNovelPress],
);
const { donutEntries, colors: pluginColors } = pluginData;
const pluginListHeader = useCallback(
() => (
<View>
<DonutChartWithLegend
title={getString('statsScreen.pluginDistribution')}
entries={donutEntries}
colors={pluginColors}
theme={theme}
centerLabel={getString('statsScreen.plugins')}
/>
</View>
),
[donutEntries, pluginColors, theme],
);
return (
<LegendList
style={styles.list}
contentContainerStyle={styles.listContent}
data={pluginData.nodes}
estimatedItemSize={56}
keyExtractor={item => item.pluginId}
getItemType={() => 'plugin'}
ListHeaderComponent={pluginListHeader}
renderItem={renderPluginItem}
recycleItems
showsVerticalScrollIndicator={false}
experimental_adaptiveRender={{ initialMode: 'light' }}
/>
);
};
const styles = StyleSheet.create({
list: {
paddingHorizontal: 16,
},
listContent: {
paddingTop: 16,
paddingBottom: 40,
},
});
+155 -255
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { StyleSheet, useWindowDimensions } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { TabView } from 'react-native-tab-view';
import { useTheme } from '@hooks/persisted';
import { getString } from '@i18n/translations';
@@ -9,100 +10,73 @@ import {
Appbar,
ErrorScreenV2,
LoadingScreenV2,
NovelCoverImage,
SafeAreaView,
TopTabBar,
} from '@components';
import { countBy } from 'lodash-es';
import { LibraryStats } from '@database/types';
import {
getChaptersDownloadedCountFromDb,
getChaptersReadCountFromDb,
getChaptersTotalCountFromDb,
getChaptersUnreadCountFromDb,
getLibraryStatsFromDb,
getNovelGenresFromDb,
getNovelStatusFromDb,
getAggregateStatsFromDb,
getTopCategoriesByTimeSpentFromDb,
getTopNovelsByTimeSpentFromDb,
getTotalTimeSpentFromDb,
getNovelsWithGenresFromDb,
type NovelWithGenres,
} from '@database/queries/StatsQueries';
import { Row } from '@components/Common';
import { IconButton, overlay } from 'react-native-paper';
import { translateNovelStatus } from '@utils/translateEnum';
import dayjs from 'dayjs';
import { getUserAgent } from '@hooks/persisted/useUserAgent';
import { getPlugin } from '@plugins/pluginManager';
import { OverviewTab } from './OverviewTab';
import { PluginsTab } from './PluginsTab';
import { TimeTab } from './TimeTab';
function formatTimeSpent(totalMs: number | undefined) {
if (totalMs === undefined || totalMs <= 0) {
return getString('time.seconds', { count: 0 });
}
const d = dayjs.duration(totalMs, 'milliseconds');
const asDays = Math.floor(d.asDays());
const asHours = Math.floor(d.asHours());
const asMinutes = Math.floor(d.asMinutes());
const asSeconds = Math.floor(d.asSeconds());
const hours = Math.floor(d.hours());
const minutes = Math.floor(d.minutes());
const seconds = Math.floor(d.seconds());
if (asDays >= 1) {
return hours > 0
? `${getString('time.days', { count: asDays })} ${getString('time.hours', { count: hours })}`
: getString('time.days', { count: asDays });
}
if (asHours >= 1) {
return minutes > 0
? `${getString('time.hours', { count: asHours })} ${getString('time.minutes', { count: minutes })}`
: getString('time.hours', { count: asHours });
}
if (asMinutes >= 1) {
return seconds > 0
? `${getString('time.minutes', { count: asMinutes })} ${getString('time.seconds', { count: seconds })}`
: getString('time.minutes', { count: asMinutes });
}
return getString('time.seconds', { count: asSeconds });
}
type StatsRoute = {
key: 'overview' | 'plugins' | 'time';
title: string;
};
const StatsScreen = () => {
const theme = useTheme();
const { goBack } = useNavigation();
const navigation = useNavigation<any>();
const [isLoading, setIsLoading] = useState(true);
const [stats, setStats] = useState<LibraryStats>({});
const [error, setError] = useState<unknown>();
const [allNovels, setAllNovels] = useState<NovelWithGenres[]>([]);
const [showingNovels, setShowingNovels] = useState(true);
const [index, setIndex] = useState(0);
useEffect(() => {
let cancelled = false;
const loadStats = async () => {
try {
const res = await Promise.all([
getLibraryStatsFromDb(),
getChaptersTotalCountFromDb(),
getChaptersReadCountFromDb(),
getChaptersUnreadCountFromDb(),
getChaptersDownloadedCountFromDb(),
getNovelGenresFromDb(),
getNovelStatusFromDb(),
getTopNovelsByTimeSpentFromDb(),
getTopCategoriesByTimeSpentFromDb(),
getTotalTimeSpentFromDb(),
]);
const [aggregateStats, topNovels, topCategories, novelsWithGenres] =
await Promise.all([
getAggregateStatsFromDb(),
getTopNovelsByTimeSpentFromDb(),
getTopCategoriesByTimeSpentFromDb(),
getNovelsWithGenresFromDb(),
]);
if (!cancelled) {
setStats(
res.reduce<LibraryStats>(
(combinedStats, currentStats) => ({
...combinedStats,
...currentStats,
}),
{},
),
);
const allGenres: string[] = [];
const statusMap: Record<string, number> = {};
for (const n of novelsWithGenres) {
if (n.genres) {
allGenres.push(...n.genres.split(/\s*,\s*/));
}
const s = n.status?.trim() || 'Unknown';
statusMap[s] = (statusMap[s] ?? 0) + 1;
}
const genres = countBy(allGenres);
setStats({
...aggregateStats,
...topNovels,
...topCategories,
genres,
status: statusMap,
});
}
if (!cancelled) setAllNovels(novelsWithGenres);
} catch (err) {
if (!cancelled) {
setError(err);
@@ -121,10 +95,104 @@ const StatsScreen = () => {
};
}, []);
const handleNovelPress = useCallback(
(novel: {
id: number;
name: string;
path: string;
cover: string | null;
pluginId: string;
}) => {
navigation.navigate('ReaderStack', {
screen: 'Novel',
params: {
name: novel.name,
path: novel.path,
pluginId: novel.pluginId,
cover: novel.cover,
},
});
},
[navigation],
);
const layout = useWindowDimensions();
const routes: StatsRoute[] = useMemo(
() => [
{ key: 'overview', title: getString('generalSettings') },
{ key: 'plugins', title: getString('statsScreen.plugins') },
{ key: 'time', title: getString('statsScreen.totalTimeSpent') },
],
[],
);
const navigationState = useMemo(() => ({ index, routes }), [index, routes]);
const initialLayout = useMemo(
() => ({ width: layout.width }),
[layout.width],
);
const renderTabBar = useCallback(
(props: any) => (
<TopTabBar
{...props}
indicatorStyle={styles.tabBarIndicator}
style={[
styles.tabBar,
{
backgroundColor: theme.surface,
borderBottomColor: theme.outlineVariant,
},
]}
tabStyle={styles.tabStyle}
gap={8}
inactiveColor={theme.secondary}
activeColor={theme.primary}
android_ripple={{ color: theme.rippleColor }}
/>
),
[
theme.outlineVariant,
theme.primary,
theme.rippleColor,
theme.secondary,
theme.surface,
],
);
const renderScene = useCallback(
({ route: tabRoute }: { route: StatsRoute }) => {
switch (tabRoute.key) {
case 'overview':
return (
<OverviewTab
allNovels={allNovels}
stats={stats}
theme={theme}
onNovelPress={handleNovelPress}
navigation={navigation}
/>
);
case 'plugins':
return (
<PluginsTab
allNovels={allNovels}
theme={theme}
onNovelPress={handleNovelPress}
/>
);
case 'time':
return <TimeTab stats={stats} theme={theme} />;
}
},
[allNovels, stats, theme, handleNovelPress, navigation],
);
const Header = (
<Appbar
title={getString('statsScreen.title')}
handleGoBack={goBack}
handleGoBack={navigation.goBack}
theme={theme}
/>
);
@@ -149,198 +217,30 @@ const StatsScreen = () => {
return (
<SafeAreaView excludeTop>
{Header}
<ScrollView
style={styles.screenCtn}
contentContainerStyle={styles.contentCtn}
>
<Text style={[styles.header, { color: theme.onSurfaceVariant }]}>
{getString('generalSettings')}
</Text>
<Row style={styles.statsRow}>
<StatsCard
label={getString('statsScreen.titlesInLibrary')}
value={stats.novelsCount}
/>
<StatsCard
label={getString('statsScreen.totalTimeSpent')}
value={formatTimeSpent(stats.totalTimeSpent)}
/>
</Row>
<Row style={styles.statsRow}>
<StatsCard
label={getString('statsScreen.readChapters')}
value={stats.chaptersRead}
/>
<StatsCard
label={getString('statsScreen.totalChapters')}
value={stats.chaptersCount}
/>
</Row>
<Row style={styles.statsRow}>
<StatsCard
label={getString('statsScreen.unreadChapters')}
value={stats.chaptersUnread}
/>
<StatsCard
label={getString('statsScreen.downloadedChapters')}
value={stats.chaptersDownloaded}
/>
</Row>
<Row style={styles.statsRow}>
<StatsCard
label={getString('statsScreen.sources')}
value={stats.sourcesCount}
/>
</Row>
<Text style={[styles.header, { color: theme.onSurfaceVariant }]}>
{getString('statsScreen.genreDistribution')}
</Text>
<Row style={[styles.statsRow, styles.genreRow]}>
{Object.entries(stats.genres || {}).map(item => (
<StatsCard key={item[0]} label={item[0]} value={item[1]} />
))}
</Row>
<Text style={[styles.header, { color: theme.onSurfaceVariant }]}>
{getString('statsScreen.statusDistribution')}
</Text>
<Row style={[styles.statsRow, styles.genreRow]}>
{Object.entries(stats.status || {}).map(item => (
<StatsCard
key={item[0]}
label={translateNovelStatus(item[0])}
value={item[1]}
/>
))}
</Row>
<View style={styles.timeSpentHeader}>
<Text style={[styles.header, { color: theme.onSurfaceVariant }]}>
{showingNovels ? getString('statsScreen.topNovelsByTimeSpent') : getString('statsScreen.topCategoriesByTimeSpent')}
</Text>
<IconButton
icon={showingNovels ? 'label-outline' : 'book'}
iconColor={theme.onSurfaceVariant}
onPress={() => setShowingNovels(!showingNovels)}
accessibilityRole="button"
accessibilityLabel={showingNovels ? getString('statsScreen.showCategories') : getString('statsScreen.showNovels')}
/>
</View>
{showingNovels && stats.topNovelsByTimeSpent?.map((novel, _) => {
const plugin = getPlugin(novel.pluginId);
const headers = plugin?.imageRequestInit?.headers || { 'User-Agent': getUserAgent() };
const requestInit = {...plugin?.imageRequestInit, headers };
return <View key={novel.id} style={styles.timeSpentRow}>
<NovelCoverImage
uri={novel.cover}
requestInit={requestInit}
theme={theme}
iconSize={22}
style={styles.timeSpentNovelCover}
contentFit='cover'
/>
<View>
<Text style={[styles.timeSpentLabel, { color: theme.onSurface }]}>
{novel.name}
</Text>
<Text style={{ color: theme.onSurfaceVariant }}>
{formatTimeSpent(novel.timeSpent)}
</Text>
</View>
</View>
})}
{!showingNovels && stats.topCategoriesByTimeSpent?.map((category, _) => {
return <View key={category.id} style={styles.timeSpentRow}>
<View>
<Text style={[styles.timeSpentLabel, { color: theme.onSurface }]}>
{category.name}
</Text>
<Text style={{ color: theme.onSurfaceVariant }}>
{formatTimeSpent(category.timeSpent)}
</Text>
</View>
</View>
})}
</ScrollView>
<TabView
navigationState={navigationState}
renderTabBar={renderTabBar}
renderScene={renderScene}
onIndexChange={setIndex}
initialLayout={initialLayout}
lazy
/>
</SafeAreaView>
);
};
export default StatsScreen;
export const StatsCard: React.FC<{ label: string; value?: string | number }> = ({
label,
value = 0,
}) => {
const theme = useTheme();
if (!label) {
return null;
}
return (
<View
style={[
styles.statsCardCtn,
{
backgroundColor: theme.isDark
? overlay(2, theme.surface)
: theme.secondaryContainer,
},
]}
>
<Text style={[styles.statsVal, { color: theme.primary }]}>{value}</Text>
<Text style={{ color: theme.onSurface }}> {label}</Text>
</View>
);
};
const styles = StyleSheet.create({
contentCtn: {
paddingBottom: 40,
tabBar: {
borderBottomWidth: 1,
elevation: 0,
},
genreRow: {
flexWrap: 'wrap',
tabBarIndicator: {
backgroundColor: '#000',
height: 3,
},
header: {
fontWeight: 'bold',
paddingVertical: 16,
},
screenCtn: {
paddingHorizontal: 16,
},
statsCardCtn: {
alignItems: 'center',
borderRadius: 12,
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.25)',
justifyContent: 'center',
margin: 4,
paddingHorizontal: 8,
paddingVertical: 12,
},
statsRow: {
justifyContent: 'center',
marginBottom: 8,
},
statsVal: {
fontSize: 16,
fontWeight: 'bold',
},
timeSpentHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
timeSpentRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
timeSpentNovelCover: {
width: 50,
aspectRatio: 2 / 3,
marginRight: 8,
borderRadius: 4,
},
timeSpentLabel: {
fontWeight: 'bold',
tabStyle: {
flex: 1,
},
});
+298
View File
@@ -0,0 +1,298 @@
import { useCallback, useMemo, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { LegendList } from '@legendapp/list/react-native';
import { IconButton } from 'react-native-paper';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { getString } from '@i18n/translations';
import { NovelCoverImage } from '@components';
import { getPlugin } from '@plugins/pluginManager';
import { getUserAgent } from '@hooks/persisted/useUserAgent';
import { formatTimeSpent } from './utils';
import type { ThemeColors } from '@theme/types';
import type { LibraryStats } from '@database/types';
dayjs.extend(duration);
type TimeSpentItem =
| {
type: 'novel';
id: number;
pluginId: string;
name: string;
cover: string | null;
timeSpent: number;
}
| { type: 'category'; id: number; name: string; timeSpent: number };
interface TimeTabProps {
stats: LibraryStats;
theme: ThemeColors;
}
function formatTotalTimeParts(totalMs: number | undefined) {
if (!totalMs || totalMs <= 0) return null;
const d = dayjs.duration(totalMs, 'milliseconds');
const days = Math.floor(d.asDays());
const hours = d.hours();
const minutes = d.minutes();
return { days, hours, minutes };
}
export const TimeTab: React.FC<TimeTabProps> = ({ stats, theme }) => {
const [showingNovels, setShowingNovels] = useState(true);
const timeSpentData = useMemo<TimeSpentItem[]>(() => {
if (showingNovels) {
return (stats.topNovelsByTimeSpent ?? []).map(n => ({
type: 'novel' as const,
id: n.id,
pluginId: n.pluginId,
name: n.name,
cover: n.cover,
timeSpent: n.timeSpent,
}));
}
return (stats.topCategoriesByTimeSpent ?? []).map(c => ({
type: 'category' as const,
id: c.id,
name: c.name,
timeSpent: c.timeSpent,
}));
}, [
showingNovels,
stats.topNovelsByTimeSpent,
stats.topCategoriesByTimeSpent,
]);
const renderTimeItem = useCallback(
({ item }: { item: TimeSpentItem }) => {
if (item.type === 'novel') {
const plugin = getPlugin(item.pluginId);
const headers = plugin?.imageRequestInit?.headers || {
'User-Agent': getUserAgent(),
};
const requestInit = { ...plugin?.imageRequestInit, headers };
return (
<View style={styles.timeSpentRow}>
<NovelCoverImage
uri={item.cover}
requestInit={requestInit}
theme={theme}
iconSize={22}
style={styles.timeSpentNovelCover}
contentFit="cover"
/>
<View>
<Text style={[styles.timeSpentLabel, { color: theme.onSurface }]}>
{item.name}
</Text>
<Text style={{ color: theme.onSurfaceVariant }}>
{formatTimeSpent(item.timeSpent)}
</Text>
</View>
</View>
);
}
return (
<View style={styles.timeSpentRow}>
<View>
<Text style={[styles.timeSpentLabel, { color: theme.onSurface }]}>
{item.name}
</Text>
<Text style={{ color: theme.onSurfaceVariant }}>
{formatTimeSpent(item.timeSpent)}
</Text>
</View>
</View>
);
},
[theme],
);
const totalTimeParts = formatTotalTimeParts(stats.totalTimeSpent);
const formatTwoNumbers = (timeSpent: number) =>
timeSpent <= 9 ? `0${timeSpent}` : timeSpent;
const timeListHeader = useCallback(
() => (
<>
<View style={styles.totalTimeContainer}>
<Text
style={[styles.totalTimeLabel, { color: theme.onSurfaceVariant }]}
>
{getString('statsScreen.totalTimeSpent')}
</Text>
{totalTimeParts ? (
<View style={styles.totalTimeValueRow}>
{totalTimeParts.days > 0 && (
<View style={styles.totalTimeBlock}>
<Text
style={[styles.totalTimeNumber, { color: theme.primary }]}
>
{formatTwoNumbers(totalTimeParts.days)}
</Text>
<Text
style={[
styles.totalTimeUnit,
{ color: theme.onSurfaceVariant },
]}
>
{getString('time.days', { count: totalTimeParts.days })
.replace(/\d+/, '')
.trim()}
</Text>
</View>
)}
{totalTimeParts.hours > 0 && (
<View style={styles.totalTimeBlock}>
<Text
style={[styles.totalTimeNumber, { color: theme.primary }]}
>
{formatTwoNumbers(totalTimeParts.hours)}
</Text>
<Text
style={[
styles.totalTimeUnit,
{ color: theme.onSurfaceVariant },
]}
>
{getString('time.hours', { count: totalTimeParts.hours })
.replace(/\d+/, '')
.trim()}
</Text>
</View>
)}
<View style={styles.totalTimeBlock}>
<Text
style={[styles.totalTimeNumber, { color: theme.primary }]}
>
{formatTwoNumbers(totalTimeParts.minutes)}
</Text>
<Text
style={[
styles.totalTimeUnit,
{ color: theme.onSurfaceVariant },
]}
>
{getString('time.minutes', {
count: totalTimeParts.minutes,
})
.replace(/\d+/, '')
.trim()}
</Text>
</View>
</View>
) : (
<Text
style={[styles.totalTimeEmpty, { color: theme.onSurfaceVariant }]}
>
{formatTimeSpent(stats.totalTimeSpent)}
</Text>
)}
</View>
<View style={styles.timeSpentHeader}>
<Text style={[styles.header, { color: theme.onSurfaceVariant }]}>
{showingNovels
? getString('statsScreen.topNovelsByTimeSpent')
: getString('statsScreen.topCategoriesByTimeSpent')}
</Text>
<IconButton
icon={showingNovels ? 'label-outline' : 'book'}
iconColor={theme.onSurfaceVariant}
onPress={() => setShowingNovels(!showingNovels)}
accessibilityRole="button"
accessibilityLabel={
showingNovels
? getString('statsScreen.showCategories')
: getString('statsScreen.showNovels')
}
/>
</View>
</>
),
[stats.totalTimeSpent, theme, showingNovels, totalTimeParts],
);
return (
<LegendList
style={styles.list}
contentContainerStyle={styles.listContent}
data={timeSpentData}
estimatedItemSize={56}
getItemType={item => item.type}
keyExtractor={item => `${item.type}-${item.id}`}
ListHeaderComponent={timeListHeader}
recycleItems
renderItem={renderTimeItem}
showsVerticalScrollIndicator={false}
/>
);
};
const styles = StyleSheet.create({
list: {
paddingHorizontal: 16,
},
listContent: {
paddingTop: 16,
paddingBottom: 40,
},
totalTimeContainer: {
alignItems: 'center',
paddingVertical: 16,
},
totalTimeLabel: {
fontSize: 14,
marginBottom: 12,
},
totalTimeValueRow: {
flexDirection: 'row',
gap: 20,
alignItems: 'flex-end',
},
totalTimeBlock: {
alignItems: 'center',
},
totalTimeNumber: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 38,
},
totalTimeUnit: {
fontSize: 12,
marginTop: 2,
},
totalTimeEmpty: {
fontSize: 16,
},
timeSpentHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
timeSpentRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
timeSpentNovelCover: {
width: 50,
aspectRatio: 2 / 3,
marginRight: 8,
borderRadius: 4,
},
timeSpentLabel: {
fontWeight: 'bold',
},
header: {
fontWeight: 'bold',
paddingVertical: 16,
},
});
@@ -0,0 +1,118 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
LayoutChangeEvent,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native';
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
interface AnimatedHeightProps {
expanded: boolean;
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
duration?: number;
}
const AnimatedHeight: React.FC<AnimatedHeightProps> = ({
expanded,
children,
style,
duration = 250,
}) => {
const [measuredHeight, setMeasuredHeight] = useState(0);
const height = useSharedValue(0);
const opacity = useSharedValue(0);
const overflowVisible = useSharedValue(false);
const onLayout = useCallback(
(event: LayoutChangeEvent) => {
const nextHeight = event.nativeEvent.layout.height;
if (nextHeight <= 0) {
return;
}
setMeasuredHeight(currentHeight =>
Math.abs(currentHeight - nextHeight) > 0.5 ? nextHeight : currentHeight,
);
},
[setMeasuredHeight],
);
useEffect(() => {
cancelAnimation(height);
cancelAnimation(opacity);
overflowVisible.value = false;
if (!expanded) {
height.value = withTiming(0, {
duration: duration * 0.7,
easing: Easing.in(Easing.cubic),
});
opacity.value = withTiming(0, {
duration: duration * 0.7,
easing: Easing.in(Easing.cubic),
});
return;
}
if (measuredHeight === 0) {
return;
}
height.value = withTiming(
measuredHeight,
{
duration,
easing: Easing.out(Easing.cubic),
},
finished => {
if (finished) {
overflowVisible.value = true;
}
},
);
opacity.value = withTiming(1, {
duration: duration * 0.6,
easing: Easing.out(Easing.cubic),
});
}, [duration, expanded, height, measuredHeight, opacity, overflowVisible]);
const animatedStyle = useAnimatedStyle(() => ({
height: height.value,
opacity: opacity.value,
overflow: overflowVisible.value ? 'visible' : 'hidden',
}));
return (
<Animated.View style={[style, animatedStyle]}>
<View onLayout={onLayout} style={styles.content}>
{children}
</View>
</Animated.View>
);
};
const styles = StyleSheet.create({
content: {
position: 'absolute',
top: 14,
right: 0,
left: 0,
},
});
export default React.memo(AnimatedHeight);
@@ -0,0 +1,110 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { useTheme } from '@hooks/persisted';
import { getString } from '@i18n/translations';
interface ChapterBarProps {
read: number;
total: number;
downloaded: number;
}
export const ChapterBar: React.FC<ChapterBarProps> = ({
read,
total,
downloaded,
}) => {
const theme = useTheme();
const readPercent = total > 0 ? Math.min(read / total, 1) : 0;
return (
<View style={styles.container}>
<Text style={[styles.totalLabel, { color: theme.onSurface }]}>
{getString('statsScreen.totalChapters')}
</Text>
<Text style={[styles.totalCount, { color: theme.onSurface }]}>
{total}
</Text>
<View style={[styles.track, { backgroundColor: theme.surfaceVariant }]}>
<View
style={[
styles.fill,
{
width: `${readPercent * 100}%` as any,
backgroundColor: theme.primary,
},
]}
/>
</View>
{total > 0 && (
<View style={styles.labelsRow}>
<View style={styles.labelCol}>
<Text style={{ color: theme.onSurfaceVariant, fontSize: 12 }}>
{getString('statsScreen.readChapters')}
</Text>
<Text
style={{
color: theme.onSurface,
fontWeight: 'bold',
fontSize: 14,
}}
>
{read}
</Text>
</View>
<View style={styles.labelCol}>
<Text style={{ color: theme.onSurfaceVariant, fontSize: 12 }}>
{getString('statsScreen.downloadedChapters')}
</Text>
<Text
style={{
color: theme.onSurface,
fontWeight: 'bold',
fontSize: 14,
}}
>
{downloaded}
</Text>
</View>
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: 12,
},
totalLabel: {
fontSize: 14,
fontWeight: 'bold',
textAlign: 'center',
},
totalCount: {
fontSize: 22,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 4,
},
track: {
height: 20,
borderRadius: 10,
overflow: 'hidden',
},
fill: {
height: '100%',
borderRadius: 10,
},
labelsRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginTop: 4,
},
labelCol: {
flex: 1,
alignItems: 'center',
},
});
@@ -0,0 +1,93 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import type { ThemeColors } from '@theme/types';
import Animated from 'react-native-reanimated';
export interface ChartLegendEntry {
key: string;
label: string;
value: number;
color: string;
}
interface ChartLegendProps {
entries: ChartLegendEntry[];
highlightedKey?: string;
onEntryPress: (key: string) => void;
theme: ThemeColors;
}
const ChartLegend: React.FC<ChartLegendProps> = ({
entries,
highlightedKey,
onEntryPress,
theme,
}) => {
return (
<View style={styles.legendContainer}>
{entries.map(entry => {
const isHighlighted = highlightedKey === entry.key;
return (
<Pressable
key={entry.key}
onPress={() => onEntryPress(entry.key)}
style={styles.legendRow}
>
<Animated.View
style={[
styles.legendDot,
{
backgroundColor: entry.color,
borderWidth: isHighlighted ? 2 : 0,
width: isHighlighted ? 16 : 12,
height: isHighlighted ? 16 : 12,
marginLeft: isHighlighted ? 0 : 2,
marginRight: isHighlighted ? 6 : 8,
transitionDuration: '150ms',
transitionProperty: 'all',
borderColor: theme.onSurface,
},
]}
/>
<Text
style={[styles.legendLabel, { color: theme.onSurface }]}
numberOfLines={1}
>
{entry.label}
</Text>
<Text
style={[styles.legendValue, { color: theme.onSurfaceVariant }]}
>
{entry.value}
</Text>
</Pressable>
);
})}
</View>
);
};
const styles = StyleSheet.create({
legendContainer: {
marginBottom: 8,
},
legendRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
},
legendDot: {
borderRadius: 8,
},
legendLabel: {
flex: 1,
fontSize: 14,
},
legendValue: {
fontSize: 14,
textAlign: 'right',
width: 40,
},
});
export default ChartLegend;
@@ -0,0 +1,248 @@
import React, { useMemo } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import Svg, { Path, G, PathProps } from 'react-native-svg';
import { useTheme } from '@hooks/persisted';
import { ThemeColors } from '@theme/types';
import color from 'color';
import Animated, {
ReduceMotion,
useAnimatedProps,
withSpring,
} from 'react-native-reanimated';
const PULL_OFFSET = 8;
const AnimatedPath = Animated.createAnimatedComponent(Path);
function toRadians(angle: number) {
return (angle * Math.PI) / 180;
}
export function getDonutPalette(
keys: string[],
theme: ThemeColors,
): Record<string, string> {
const bases = [
theme.primary,
theme.primaryContainer,
theme.tertiary,
theme.tertiaryContainer,
theme.secondary,
theme.secondaryContainer,
theme.surface,
theme.surfaceVariant,
theme.onPrimary,
];
const result: Record<string, string> = {};
keys.forEach((key, i) => {
const base = bases[i % bases.length];
const cycle = Math.floor(i / bases.length);
if (cycle === 0) {
result[key] = base;
} else {
const factor = cycle * 0.15;
result[key] = theme.isDark
? color(base).lighten(factor).hex()
: color(base).darken(factor).hex();
}
});
return result;
}
interface DonutChartProps {
entries: { key: string; value: number }[];
size: number;
thickness: number;
colors: Record<string, string>;
centerLabel?: string;
highlightedKey?: string;
onSegmentPress?: (key: string) => void;
}
interface SegmentData {
key: string;
color: string;
startAngle: number;
endAngle: number;
}
type DonutChartSegmentProps = SegmentData & {
segKey: string;
cx: number;
cy: number;
radius: number;
innerRadius: number;
isHighlighted: boolean;
onPress?: (key: string) => void;
};
const config = {
duration: 150,
dampingRatio: 0.75,
mass: 4,
overshootClamping: undefined,
energyThreshold: 6e-9,
velocity: 0,
reduceMotion: ReduceMotion.System,
};
const DonutChartSegment = ({
segKey,
color: segColor,
startAngle,
endAngle,
onPress,
isHighlighted,
cx,
cy,
radius,
innerRadius,
}: DonutChartSegmentProps) => {
const path = useMemo(() => {
const polar = (angle: number, r: number) => ({
x: cx + r * Math.cos(toRadians(angle)),
y: cy + r * Math.sin(toRadians(angle)),
});
const outerStart = polar(startAngle, radius);
const outerEnd = polar(endAngle, radius);
const innerStart = polar(startAngle, innerRadius);
const innerEnd = polar(endAngle, innerRadius);
const sweepAngle = (((endAngle - startAngle) % 360) + 360) % 360;
const largeArcFlag = sweepAngle > 180 ? 1 : 0;
return [
`M ${innerStart.x} ${innerStart.y}`,
`L ${outerStart.x} ${outerStart.y}`,
`A ${radius} ${radius}`,
`0 ${largeArcFlag} 1`,
`${outerEnd.x} ${outerEnd.y}`,
`L ${innerEnd.x} ${innerEnd.y}`,
`A ${innerRadius} ${innerRadius}`,
`0 ${largeArcFlag} 0`,
`${innerStart.x} ${innerStart.y}`,
'Z',
].join(' ');
}, [cx, cy, startAngle, endAngle, radius, innerRadius]);
const animatedProps = useAnimatedProps<PathProps>(() => {
const midAngle = (startAngle + endAngle) / 2;
const midAngleRadians = (midAngle * Math.PI) / 180;
const targetX = PULL_OFFSET * Math.cos(midAngleRadians);
const targetY = PULL_OFFSET * Math.sin(midAngleRadians);
return {
transform: [
{
translateX: withSpring(isHighlighted ? targetX : 0, config),
},
{
translateY: withSpring(isHighlighted ? targetY : 0, config),
},
],
};
}, [isHighlighted]);
return (
<G onPress={() => onPress?.(segKey)}>
<AnimatedPath animatedProps={animatedProps} d={path} fill={segColor} />
</G>
);
};
export const DonutChart: React.FC<DonutChartProps> = ({
entries,
size,
thickness,
colors,
centerLabel,
highlightedKey,
onSegmentPress,
}) => {
const theme = useTheme();
const total = entries.reduce((s, e) => s + e.value, 0);
if (total === 0) return null;
const totalSize = size + PULL_OFFSET * 2;
const cx = totalSize / 2;
const cy = totalSize / 2;
const radius = size / 2 - 1; // slight inset to avoid clipping
const innerRadius = radius - thickness;
const active = entries.filter(e => e.value > 0);
// Build accumulated angles (math coords, CW from 3 o'clock)
const segments: SegmentData[] = [];
let cursor = 0;
for (const entry of active) {
const angle = (entry.value / total) * 360;
segments.push({
key: entry.key,
color: colors[entry.key] || theme.outline,
startAngle: cursor,
endAngle: cursor + angle,
});
cursor += angle;
}
return (
<View
style={{
width: totalSize,
height: totalSize,
position: 'relative',
}}
>
<Svg width={totalSize} height={totalSize}>
<G>
{segments.map(seg => {
const { key, ...p } = seg;
const isHighlighted = highlightedKey === key;
const props = {
...p,
segKey: key,
cx,
cy,
radius,
innerRadius,
isHighlighted,
onPress: onSegmentPress,
};
return <DonutChartSegment key={key} {...props} />;
})}
</G>
</Svg>
{/* Center count */}
<View
style={[styles.centerLabel, { width: totalSize, height: totalSize }]}
>
<Text style={[styles.centerCount, { color: theme.onSurface }]}>
{total}
</Text>
<Text style={[styles.centerUnit, { color: theme.onSurfaceVariant }]}>
{centerLabel}
</Text>
</View>
</View>
);
};
const styles = StyleSheet.create({
centerLabel: {
position: 'absolute',
top: 0,
left: 0,
justifyContent: 'center',
alignItems: 'center',
},
centerCount: {
fontSize: 22,
fontWeight: 'bold',
},
centerUnit: {
fontSize: 11,
},
});
@@ -0,0 +1,84 @@
import React, { useState, useCallback } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { DonutChart } from './DonutChart';
import ChartLegend, { type ChartLegendEntry } from './ChartLegend';
import type { ThemeColors } from '@theme/types';
interface DonutChartWithLegendProps {
title: string;
entries: { key: string; value: number }[];
size?: number;
thickness?: number;
colors: Record<string, string>;
theme: ThemeColors;
centerLabel?: string;
getLabel?: (key: string) => string;
}
const DonutChartWithLegend: React.FC<DonutChartWithLegendProps> = ({
title,
entries,
size = 160,
thickness = 28,
colors,
theme,
centerLabel,
getLabel,
}) => {
const [highlightedKey, setHighlightedKey] = useState<string | undefined>(
undefined,
);
const handleSegmentPress = useCallback(
(key: string) =>
setHighlightedKey(prev => (prev === key ? undefined : key)),
[],
);
const legendEntries: ChartLegendEntry[] = entries
.filter(e => e.value > 0)
.map(e => ({
key: e.key,
label: getLabel ? getLabel(e.key) : e.key,
value: e.value,
color: colors[e.key] || theme.outline,
}))
.sort((a, b) => b.value - a.value);
return (
<View>
<Text style={[styles.header, { color: theme.onSurfaceVariant }]}>
{title}
</Text>
<View style={styles.donutContainer}>
<DonutChart
entries={entries}
size={size}
thickness={thickness}
colors={colors}
centerLabel={centerLabel}
highlightedKey={highlightedKey}
onSegmentPress={handleSegmentPress}
/>
</View>
<ChartLegend
entries={legendEntries}
highlightedKey={highlightedKey}
onEntryPress={handleSegmentPress}
theme={theme}
/>
</View>
);
};
const styles = StyleSheet.create({
header: {
fontWeight: 'bold',
paddingVertical: 16,
},
donutContainer: {
alignItems: 'center',
marginBottom: 12,
},
});
export default DonutChartWithLegend;
@@ -0,0 +1,122 @@
import React, { useMemo } from 'react';
import {
useRecyclingState,
useAdaptiveRender,
} from '@legendapp/list/react-native';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
import { NovelCoverImage } from '@components';
import AnimatedHeight from './AnimatedHeight';
import type { ThemeColors } from '@theme/types';
import type { NovelWithGenres } from '@database/queries/StatsQueries';
interface PluginSectionProps {
pluginId: string;
name: string;
count: number;
novels: NovelWithGenres[];
theme: ThemeColors;
onNovelPress: (novel: {
id: number;
name: string;
path: string;
cover: string | null;
pluginId: string;
}) => void;
}
const PluginSection: React.FC<PluginSectionProps> = ({
pluginId,
name,
count,
novels,
theme,
onNovelPress,
}) => {
const [expanded, setExpanded] = useRecyclingState(false);
const adaptiveRender = useAdaptiveRender();
const pluginNovels = useMemo(
() => novels.filter(n => n.pluginId === pluginId),
[novels, pluginId],
);
return (
<View>
<Pressable
onPress={() => setExpanded(e => !e)}
accessibilityRole="button"
style={({ pressed }) => [
styles.header,
{ borderBottomColor: theme.outlineVariant },
pressed && { opacity: 0.7 },
]}
>
<Text
style={[styles.name, { color: theme.onSurface }]}
numberOfLines={1}
>
{name}
</Text>
<View style={styles.headerRight}>
<Text style={[styles.count, { color: theme.onSurfaceVariant }]}>
{count}
</Text>
<MaterialCommunityIcons
name={expanded ? 'chevron-down' : 'chevron-right'}
color={theme.onSurfaceVariant}
size={24}
/>
</View>
</Pressable>
{adaptiveRender === 'light' ? null : (
<AnimatedHeight expanded={expanded}>
{pluginNovels.map(novel => (
<Pressable
key={novel.id}
onPress={() => onNovelPress(novel)}
style={styles.novelRow}
>
<NovelCoverImage
uri={novel.cover}
requestInit={undefined}
theme={theme}
iconSize={18}
style={styles.novelCover}
contentFit="cover"
/>
<Text
style={[styles.novelName, { color: theme.onSurface }]}
numberOfLines={1}
>
{novel.name}
</Text>
</Pressable>
))}
</AnimatedHeight>
)}
</View>
);
};
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
borderBottomWidth: 1,
},
name: { fontWeight: '600', flex: 1 },
headerRight: { flexDirection: 'row', alignItems: 'center' },
count: { marginRight: 8 },
novelRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
novelCover: {
width: 36,
aspectRatio: 2 / 3,
marginRight: 8,
borderRadius: 4,
},
novelName: { flex: 1 },
});
export default PluginSection;
@@ -0,0 +1,48 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { useTheme } from '@hooks/persisted';
import { overlay } from 'react-native-paper';
const styles = StyleSheet.create({
card: {
alignItems: 'center',
borderRadius: 12,
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.25)',
justifyContent: 'center',
margin: 4,
paddingHorizontal: 8,
paddingVertical: 12,
},
value: {
fontSize: 16,
fontWeight: 'bold',
},
});
export const StatsCard: React.FC<{
label: string;
value?: string | number;
}> = ({ label, value = 0 }) => {
const theme = useTheme();
if (!label) {
return null;
}
return (
<View
style={[
styles.card,
{
backgroundColor: theme.isDark
? overlay(2, theme.surface)
: theme.secondaryContainer,
},
]}
>
<Text style={[styles.value, { color: theme.primary }]}>{value}</Text>
<Text style={{ color: theme.onSurface }}> {label}</Text>
</View>
);
};
@@ -0,0 +1,7 @@
export { default as AnimatedHeight } from './AnimatedHeight';
export { StatsCard } from './StatsCard';
export { DonutChart, getDonutPalette } from './DonutChart';
export { ChapterBar } from './ChapterBar';
export { default as PluginSection } from './PluginSection';
export { default as ChartLegend } from './ChartLegend';
export { default as DonutChartWithLegend } from './DonutChartWithLegend';
+154
View File
@@ -0,0 +1,154 @@
import { getString } from '@i18n/translations';
import dayjs from 'dayjs';
export function normalizeGenre(genre: string): string {
return genre.trim().toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
}
export function normalizeGenreDistribution(
genres: Record<string, number>,
): { name: string; count: number }[] {
const merged: Record<string, number> = {};
for (const [key, count] of Object.entries(genres)) {
const normalized = normalizeGenre(key);
merged[normalized] = (merged[normalized] || 0) + count;
}
return Object.entries(merged)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
}
export function formatTimeSpent(totalMs: number | undefined) {
if (totalMs === undefined || totalMs <= 0) {
return getString('time.seconds', { count: 0 });
}
const d = dayjs.duration(totalMs, 'milliseconds');
const asDays = Math.floor(d.asDays());
const asHours = Math.floor(d.asHours());
const asMinutes = Math.floor(d.asMinutes());
const asSeconds = Math.floor(d.asSeconds());
const hours = Math.floor(d.hours());
const minutes = Math.floor(d.minutes());
const seconds = Math.floor(d.seconds());
if (asDays >= 1) {
return hours > 0
? `${getString('time.days', { count: asDays })} ${getString('time.hours', { count: hours })}`
: getString('time.days', { count: asDays });
}
if (asHours >= 1) {
return minutes > 0
? `${getString('time.hours', { count: asHours })} ${getString('time.minutes', { count: minutes })}`
: getString('time.hours', { count: asHours });
}
if (asMinutes >= 1) {
return seconds > 0
? `${getString('time.minutes', { count: asMinutes })} ${getString('time.seconds', { count: seconds })}`
: getString('time.minutes', { count: asMinutes });
}
return getString('time.seconds', { count: asSeconds });
}
export function groupGenreDistribution(
entries: { name: string; count: number }[]
): { category: string; total: number; items: { name: string; count: number; isCategory: boolean }[] }[] {
const knownRoots = [
'Fantasy', 'Romance', 'Adventure', 'Action', 'Horror', 'Mystery',
'Comedy', 'Drama', 'Sci-Fi', 'Fiction', 'Lead', 'Hero',
];
interface Entry {
name: string;
count: number;
normalized: string;
}
const normalizedEntries: Entry[] = entries.map(e => ({
...e,
normalized: e.name.trim().toLowerCase().replace(/\b\w/g, c => c.toUpperCase()),
}));
const assigned = new Set<number>();
const groups: Map<string, { total: number; items: { name: string; count: number; isCategory: boolean }[] }> = new Map();
// Phase 1: match entries against known roots
for (const root of knownRoots) {
const matches = normalizedEntries.filter((e, i) => {
if (assigned.has(i)) return false;
if (e.normalized === root) return false; // the root itself
return e.normalized.includes(root) || root.includes(e.normalized);
});
if (matches.length > 0 || normalizedEntries.some((e, i) => !assigned.has(i) && e.normalized === root)) {
const rootEntry = normalizedEntries.find((e, i) => !assigned.has(i) && e.normalized === root);
const rootIdx = normalizedEntries.findIndex((e, i) => !assigned.has(i) && e.normalized === root);
if (rootEntry && rootIdx >= 0) {
assigned.add(rootIdx);
const group = groups.get(root) || { total: 0, items: [] };
group.total += rootEntry.count;
group.items.push({ name: rootEntry.name, count: rootEntry.count, isCategory: true });
groups.set(root, group);
}
for (const m of matches) {
const idx = normalizedEntries.indexOf(m);
assigned.add(idx);
const group = groups.get(root) || { total: 0, items: [] };
group.total += m.count;
group.items.push({ name: m.name, count: m.count, isCategory: false });
groups.set(root, group);
}
}
}
// Phase 2: synthetic categories from trailing-word frequency
const ungrouped = normalizedEntries.filter((_, i) => !assigned.has(i));
const wordCounts: Map<string, { count: number; indices: number[] }> = new Map();
for (let i = 0; i < ungrouped.length; i++) {
const words = ungrouped[i].normalized.split(' ');
if (words.length > 1) {
const lastWord = words[words.length - 1];
const entry = wordCounts.get(lastWord) || { count: 0, indices: [] };
entry.count++;
entry.indices.push(normalizedEntries.indexOf(ungrouped[i]));
wordCounts.set(lastWord, entry);
}
}
for (const [word, info] of wordCounts) {
if (info.count >= 2) {
for (const idx of info.indices) {
if (!assigned.has(idx)) {
assigned.add(idx);
const e = normalizedEntries[idx];
const group = groups.get(word) || { total: 0, items: [] };
group.total += e.count;
group.items.push({ name: e.name, count: e.count, isCategory: false });
groups.set(word, group);
}
}
}
}
// Phase 3: leftovers become their own categories
for (let i = 0; i < normalizedEntries.length; i++) {
if (!assigned.has(i)) {
assigned.add(i);
const e = normalizedEntries[i];
const group = groups.get(e.name) || { total: 0, items: [] };
group.total += e.count;
group.items.push({ name: e.name, count: e.count, isCategory: true });
groups.set(e.name, group);
}
}
// Sort: categories by total descending, items by count descending with isCategory first
return Array.from(groups.entries())
.map(([category, g]) => ({
category,
total: g.total,
items: g.items.sort((a, b) => {
if (a.isCategory !== b.isCategory) return a.isCategory ? -1 : 1;
return b.count - a.count;
}),
}))
.sort((a, b) => b.total - a.total);
}
+10
View File
@@ -97,6 +97,16 @@ const SettingsScreen = ({ navigation }: SettingsScreenProps) => {
}
theme={theme}
/>
<List.Item
title={getString('genreStats.taxonomyTitle')}
icon="tag-multiple-outline"
onPress={() =>
navigation.navigate('SettingsStack', {
screen: 'GenreTaxonomy',
})
}
theme={theme}
/>
</ScrollView>
</SafeAreaView>
);
@@ -0,0 +1,338 @@
import { useState } from 'react';
import { FlatList, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { IconButton, TextInput } from 'react-native-paper';
import { Appbar, Dialog, List, SafeAreaView } from '@components';
import ConfirmationDialog from '@components/ConfirmationDialog/ConfirmationDialog';
import { useTheme } from '@hooks/persisted';
import { useGenreTaxonomy } from '@hooks/persisted/useGenreTaxonomy';
import { getString } from '@i18n/translations';
import type { GenreTaxonomyScreenProps } from '@navigators/types';
const SettingsTaxonomyScreen = ({ navigation }: GenreTaxonomyScreenProps) => {
const theme = useTheme();
const { taxonomy, setTaxonomy } = useGenreTaxonomy();
const [dialog, setDialog] = useState<
| { type: 'addParent' }
| { type: 'editParent'; parentName: string }
| { type: 'none' }
>({ type: 'none' });
// Form fields
const [parentName, setParentName] = useState('');
const [childName, setChildName] = useState('');
// Delete confirmation
const [deleteTarget, setDeleteTarget] = useState<
| { type: 'parent'; name: string }
| null
>(null);
const resetForm = () => {
setParentName('');
setChildName('');
};
const openDialog = (
d:
| { type: 'addParent' }
| { type: 'editParent'; parentName: string },
) => {
resetForm();
if (d.type === 'editParent') setParentName(d.parentName);
setDialog(d);
};
const handleAddParent = () => {
const name = parentName.trim();
if (!name) return;
setTaxonomy([...taxonomy, { parent: name, children: [] }]);
setDialog({ type: 'none' });
};
const handleEditParent = () => {
const name = parentName.trim();
if (!name || dialog.type !== 'editParent') return;
const oldName = dialog.parentName;
const updated = taxonomy.map(t =>
t.parent === oldName ? { ...t, parent: name } : t,
);
setTaxonomy(updated);
setDialog({ type: 'none' });
};
const handleAddChild = () => {
const name = childName.trim();
if (!name || dialog.type !== 'editParent') return;
const updated = taxonomy.map(t =>
t.parent === dialog.parentName
? { ...t, children: [...t.children, name] }
: t,
);
setTaxonomy(updated);
setChildName('');
};
const handleDeleteParent = (name: string) => {
setTaxonomy(taxonomy.filter(t => t.parent !== name));
setDeleteTarget(null);
};
const handleDeleteChild = (pName: string, cName: string) => {
const updated = taxonomy.map(t =>
t.parent === pName
? { ...t, children: t.children.filter(c => c !== cName) }
: t,
);
setTaxonomy(updated);
setDeleteTarget(null);
};
const hasTaxonomy = taxonomy.length > 0;
return (
<SafeAreaView excludeTop>
<Appbar
title={getString('genreStats.taxonomyTitle')}
handleGoBack={navigation.goBack}
theme={theme}
/>
<ScrollView
style={[{ backgroundColor: theme.background }, styles.flex]}
contentContainerStyle={styles.scrollContent}
>
{/* Parent categories section */}
<List.Section>
<List.SubHeader theme={theme}>
{getString('genreStats.parentCategories')}
</List.SubHeader>
{hasTaxonomy ? (
taxonomy.map(node => (
<Pressable
key={node.parent}
onPress={() =>
openDialog({ type: 'editParent', parentName: node.parent })
}
android_ripple={{ color: theme.rippleColor }}
>
<View style={styles.row}>
<View style={styles.rowTextContainer}>
<Text
style={[styles.rowText, { color: theme.onSurface }]}
numberOfLines={1}
>
{node.parent}
</Text>
<Text
style={[
styles.rowSubText,
{ color: theme.onSurfaceVariant },
]}
>
{getString('genreStats.subgenres', {
count: node.children.length,
})}
</Text>
</View>
<View style={styles.rowActions}>
<IconButton
icon="close"
iconColor={theme.onSurfaceVariant}
size={20}
onPress={() =>
setDeleteTarget({
type: 'parent',
name: node.parent,
})
}
/>
</View>
</View>
</Pressable>
))
) : (
<List.Item
title={getString('genreStats.noCategories')}
theme={theme}
/>
)}
<List.Item
title={getString('genreStats.addCategory')}
icon="plus"
onPress={() => openDialog({ type: 'addParent' })}
theme={theme}
/>
</List.Section>
</ScrollView>
{/* Add / Edit Parent Dialog */}
{(dialog.type === 'addParent' || dialog.type === 'editParent') && (
<Dialog.Root
visible
onDismiss={() => setDialog({ type: 'none' })}
>
<Dialog.Header>
<Dialog.Title>
{dialog.type === 'addParent'
? getString('genreStats.addCategory')
: dialog.parentName}
</Dialog.Title>
</Dialog.Header>
<Dialog.Content>
<TextInput
label={getString('genreStats.parentNamePlaceholder')}
value={parentName}
onChangeText={setParentName}
mode="outlined"
/>
{dialog.type === 'editParent' &&
(() => {
const node = taxonomy.find(
t => t.parent === dialog.parentName,
);
if (!node) return null;
return (
<>
{node.children.length > 0 && (
<FlatList
data={node.children}
keyExtractor={item => item}
renderItem={({ item }) => (
<View style={styles.childRow}>
<Text
style={[
styles.childText,
{ color: theme.onSurface },
]}
numberOfLines={1}
>
{item}
</Text>
<Pressable
onPress={() =>
handleDeleteChild(dialog.parentName, item)
}
hitSlop={8}
>
<IconButton
icon="close"
iconColor={theme.onSurfaceVariant}
size={18}
/>
</Pressable>
</View>
)}
style={styles.childList}
/>
)}
<View style={styles.inlineAddChild}>
<TextInput
label={getString('genreStats.childNamePlaceholder')}
value={childName}
onChangeText={setChildName}
mode="outlined"
style={styles.inlineInput}
/>
<IconButton
icon="plus"
iconColor={theme.primary}
size={24}
disabled={!childName.trim()}
onPress={handleAddChild}
/>
</View>
</>
);
})()}
</Dialog.Content>
<Dialog.Actions>
<Dialog.Action onPress={() => setDialog({ type: 'none' })}>
{getString('common.cancel')}
</Dialog.Action>
<Dialog.Action
onPress={
dialog.type === 'addParent'
? handleAddParent
: handleEditParent
}
>
{getString('common.ok')}
</Dialog.Action>
</Dialog.Actions>
</Dialog.Root>
)}
{/* Delete confirmations */}
{deleteTarget?.type === 'parent' && (
<ConfirmationDialog
visible
title={getString('genreStats.deleteConfirmTitle')}
message={getString('genreStats.deleteCategoryConfirm')}
confirmLabel={getString('common.delete')}
onConfirm={() => handleDeleteParent(deleteTarget.name)}
onDismiss={() => setDeleteTarget(null)}
/>
)}
</SafeAreaView>
);
};
export default SettingsTaxonomyScreen;
const styles = StyleSheet.create({
flex: {
flex: 1,
},
scrollContent: {
paddingBottom: 40,
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
minHeight: 48,
},
rowTextContainer: {
flex: 1,
},
rowText: {
fontSize: 16,
},
rowSubText: {
fontSize: 13,
marginTop: 2,
},
rowActions: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
childList: {
marginTop: 16,
},
childRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 8,
paddingLeft: 8,
},
childText: {
fontSize: 14,
flex: 1,
},
inlineAddChild: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 12,
},
inlineInput: {
flex: 1,
marginRight: 8,
},
});