diff --git a/package.json b/package.json index 1428f53b..e652f75e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c920af9a..50749c45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/database/queries/StatsQueries.ts b/src/database/queries/StatsQueries.ts index 4e9b7087..1b8cccc0 100644 --- a/src/database/queries/StatsQueries.ts +++ b/src/database/queries/StatsQueries.ts @@ -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 => { - const result = await dbManager - .select({ - novelsCount: count(), - sourcesCount: sql`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 => { - 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 => { - const result = await dbManager - .select({ - id: novelSchema.id, - pluginId: novelSchema.pluginId, - cover: novelSchema.cover, - name: novelSchema.name, - timeSpent: sql`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 => { - const result = await dbManager - .select({ - id: categorySchema.id, - name: categorySchema.name, - timeSpent: sql`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 => { - const result = await dbManager - .select({ totalTimeSpent: sql`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 => { - 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 => { - 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 => { 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`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 => { +/** Get top categories based on time spent */ +export const getTopCategoriesByTimeSpentFromDb = + async (): Promise => { + const result = await dbManager + .select({ + id: categorySchema.id, + name: categorySchema.name, + timeSpent: sql`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 => { - const res = await dbManager - .select({ status: novelSchema.status }) +export const getAggregateStatsFromDb = async (): Promise => { + const result = await dbManager + .select({ + novelsCount: count(), + sourcesCount: sql`COUNT(DISTINCT ${novelSchema.pluginId})`, + chaptersCount: sql`COALESCE(SUM(${novelSchema.totalChapters}), 0)`, + chaptersDownloaded: sql`COALESCE(SUM(${novelSchema.chaptersDownloaded}), 0)`, + chaptersUnread: sql`COALESCE(SUM(${novelSchema.chaptersUnread}), 0)`, + totalTimeSpent: sql`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, + }; }; diff --git a/src/database/queries/__tests__/StatsQueries.test.ts b/src/database/queries/__tests__/StatsQueries.test.ts index 51949680..b46b7bcf 100644 --- a/src/database/queries/__tests__/StatsQueries.test.ts +++ b/src/database/queries/__tests__/StatsQueries.test.ts @@ -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); - }); - }); }); diff --git a/src/hooks/persisted/useGenreTaxonomy.ts b/src/hooks/persisted/useGenreTaxonomy.ts new file mode 100644 index 00000000..d811b06b --- /dev/null +++ b/src/hooks/persisted/useGenreTaxonomy.ts @@ -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(TAXONOMY_KEY); + + const setTaxonomy = (value: TaxonomyNode[]) => setTaxonomyRaw(value); + + return { taxonomy, setTaxonomy } as const; +}; diff --git a/src/i18n/languages/en/strings.json b/src/i18n/languages/en/strings.json index 68a492dc..10257862 100644 --- a/src/i18n/languages/en/strings.json +++ b/src/i18n/languages/en/strings.json @@ -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" } } diff --git a/src/i18n/types/index.ts b/src/i18n/types/index.ts index 064947c5..f259a2f3 100644 --- a/src/i18n/types/index.ts +++ b/src/i18n/types/index.ts @@ -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'; } diff --git a/src/navigators/MoreStack.tsx b/src/navigators/MoreStack.tsx index 17c7e47e..bf895d29 100644 --- a/src/navigators/MoreStack.tsx +++ b/src/navigators/MoreStack.tsx @@ -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} /> + ); }; diff --git a/src/navigators/types/index.ts b/src/navigators/types/index.ts index acfb1287..0cdaebf0 100644 --- a/src/navigators/types/index.ts +++ b/src/navigators/types/index.ts @@ -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; + | { + name: string; + path: string; + pluginId: string; + cover: string | null; + isLocal?: boolean | null; + } + | Omit; Chapter: { novel: NovelInfo; chapter: ChapterInfo; @@ -182,6 +183,10 @@ export type LibrarySettingsScreenProps = CompositeScreenProps< StackScreenProps, StackScreenProps >; +export type GenreTaxonomyScreenProps = CompositeScreenProps< + StackScreenProps, + StackScreenProps +>; export type RespositorySettingsScreenProps = CompositeScreenProps< StackScreenProps, @@ -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 { } } } diff --git a/src/screens/GenreStatsScreen/components/GenreRow.tsx b/src/screens/GenreStatsScreen/components/GenreRow.tsx new file mode 100644 index 00000000..6fff82ee --- /dev/null +++ b/src/screens/GenreStatsScreen/components/GenreRow.tsx @@ -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 = ({ + 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 = ( + + + {name} + + {showBar && ( + + + + )} + + {count} + + + ); + + if (onPress) { + return ( + + {row} + + ); + } + + 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; diff --git a/src/screens/GenreStatsScreen/components/GenreSection.tsx b/src/screens/GenreStatsScreen/components/GenreSection.tsx new file mode 100644 index 00000000..94b26408 --- /dev/null +++ b/src/screens/GenreStatsScreen/components/GenreSection.tsx @@ -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 = ({ + 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 ( + + [ + styles.headerRow, + { borderBottomColor: theme.outlineVariant }, + pressed && { opacity: 0.7 }, + ]} + > + + + {node.name} + + {!expanded && hasChildren && ( + + {getString('genreStats.subgenres', { + count: node.children!.length, + })} + + )} + + + + {node.categoryTotal} + + + + + + {adaptiveRender === 'light' ? null : ( + + {hasChildren && + node.children!.map(child => ( + + ))} + {hasChildren && } + + + + )} + + ); +}; + +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; diff --git a/src/screens/GenreStatsScreen/components/NovelCard.tsx b/src/screens/GenreStatsScreen/components/NovelCard.tsx new file mode 100644 index 00000000..24dddaa6 --- /dev/null +++ b/src/screens/GenreStatsScreen/components/NovelCard.tsx @@ -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 = React.memo( + ({ novel, theme, onPress }) => { + return ( + + + + {novel.name} + + + ); + }, + (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; diff --git a/src/screens/GenreStatsScreen/components/NovelCarousel.tsx b/src/screens/GenreStatsScreen/components/NovelCarousel.tsx new file mode 100644 index 00000000..becff93a --- /dev/null +++ b/src/screens/GenreStatsScreen/components/NovelCarousel.tsx @@ -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 = ({ + novels, + genreName, + theme, + onNovelPress, +}) => { + const [showAll, setShowAll] = useState(false); + + if (novels.length === 0) { + return ( + + {getString('genreStats.noNovels')} + + ); + } + + 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 ( + + + {getString('genreStats.novelsIn', { genre: genreName })} + + + item.id != null && item.id !== -1 ? String(item.id) : 'see-all' + } + renderItem={({ item }) => { + if (item.id === -1) { + return ( + setShowAll(true)} + accessibilityRole="button" + accessibilityLabel={getString('genreStats.seeAllNovels')} + style={styles.seeAllCard} + > + + {getString('genreStats.seeAllNovels')} + + + + ); + } + return ( + onNovelPress(item)} + /> + ); + }} + windowSize={5} + maxToRenderPerBatch={10} + /> + + ); +}; + +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; diff --git a/src/screens/GenreStatsScreen/components/index.ts b/src/screens/GenreStatsScreen/components/index.ts new file mode 100644 index 00000000..988ce135 --- /dev/null +++ b/src/screens/GenreStatsScreen/components/index.ts @@ -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'; diff --git a/src/screens/GenreStatsScreen/taxonomy.ts b/src/screens/GenreStatsScreen/taxonomy.ts new file mode 100644 index 00000000..4b5268ba --- /dev/null +++ b/src/screens/GenreStatsScreen/taxonomy.ts @@ -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'], + }, +]; + diff --git a/src/screens/GenreStatsScreen/utils.tsx b/src/screens/GenreStatsScreen/utils.tsx new file mode 100644 index 00000000..d2abbb8e --- /dev/null +++ b/src/screens/GenreStatsScreen/utils.tsx @@ -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> + const genreMap = new Map>(); + + 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(); + 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, +): NovelWithGenres[] { + const novelMap = new Map(); + 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), + ); +} diff --git a/src/screens/StatsScreen/OverviewTab.tsx b/src/screens/StatsScreen/OverviewTab.tsx new file mode 100644 index 00000000..450e026c --- /dev/null +++ b/src/screens/StatsScreen/OverviewTab.tsx @@ -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; +} + +export const OverviewTab: React.FC = ({ + 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 }) => ( + + ), + [globalMax, allNovels, theme, onNovelPress], + ); + + const overviewListHeader = useCallback( + () => ( + + + v > 0) + .map(([k, v]) => ({ key: k, value: v }))} + colors={statusColors} + theme={theme} + centerLabel={getString('statsScreen.novels')} + getLabel={key => translateNovelStatus(key)} + /> + {tree.length > 0 && ( + + + {getString('statsScreen.genreDistribution')} + + + navigation.navigate('SettingsStack', { + screen: 'GenreTaxonomy', + }) + } + accessibilityRole="button" + accessibilityLabel={getString('genreStats.editTaxonomy')} + hitSlop={12} + > + + + + )} + + ), + [stats, theme, statusColors, tree.length, navigation], + ); + + return ( + 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, + }, +}); diff --git a/src/screens/StatsScreen/PluginsTab.tsx b/src/screens/StatsScreen/PluginsTab.tsx new file mode 100644 index 00000000..99e3623b --- /dev/null +++ b/src/screens/StatsScreen/PluginsTab.tsx @@ -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 = ({ + 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 } }) => ( + + ), + [allNovels, theme, onNovelPress], + ); + + const { donutEntries, colors: pluginColors } = pluginData; + const pluginListHeader = useCallback( + () => ( + + + + ), + [donutEntries, pluginColors, theme], + ); + + return ( + 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, + }, +}); diff --git a/src/screens/StatsScreen/StatsScreen.tsx b/src/screens/StatsScreen/StatsScreen.tsx index d6b969d3..c2747108 100644 --- a/src/screens/StatsScreen/StatsScreen.tsx +++ b/src/screens/StatsScreen/StatsScreen.tsx @@ -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(); const [isLoading, setIsLoading] = useState(true); const [stats, setStats] = useState({}); const [error, setError] = useState(); + const [allNovels, setAllNovels] = useState([]); - 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( - (combinedStats, currentStats) => ({ - ...combinedStats, - ...currentStats, - }), - {}, - ), - ); + const allGenres: string[] = []; + const statusMap: Record = {}; + 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) => ( + + ), + [ + theme.outlineVariant, + theme.primary, + theme.rippleColor, + theme.secondary, + theme.surface, + ], + ); + + const renderScene = useCallback( + ({ route: tabRoute }: { route: StatsRoute }) => { + switch (tabRoute.key) { + case 'overview': + return ( + + ); + case 'plugins': + return ( + + ); + case 'time': + return ; + } + }, + [allNovels, stats, theme, handleNovelPress, navigation], + ); + const Header = ( ); @@ -149,198 +217,30 @@ const StatsScreen = () => { return ( {Header} - - - {getString('generalSettings')} - - - - - - - - - - - - - - - - - - {getString('statsScreen.genreDistribution')} - - - {Object.entries(stats.genres || {}).map(item => ( - - ))} - - - {getString('statsScreen.statusDistribution')} - - - {Object.entries(stats.status || {}).map(item => ( - - ))} - - - - {showingNovels ? getString('statsScreen.topNovelsByTimeSpent') : getString('statsScreen.topCategoriesByTimeSpent')} - - setShowingNovels(!showingNovels)} - accessibilityRole="button" - accessibilityLabel={showingNovels ? getString('statsScreen.showCategories') : getString('statsScreen.showNovels')} - /> - - {showingNovels && stats.topNovelsByTimeSpent?.map((novel, _) => { - const plugin = getPlugin(novel.pluginId); - const headers = plugin?.imageRequestInit?.headers || { 'User-Agent': getUserAgent() }; - const requestInit = {...plugin?.imageRequestInit, headers }; - return - - - - {novel.name} - - - {formatTimeSpent(novel.timeSpent)} - - - - })} - {!showingNovels && stats.topCategoriesByTimeSpent?.map((category, _) => { - return - - - {category.name} - - - {formatTimeSpent(category.timeSpent)} - - - - })} - + ); }; export default StatsScreen; -export const StatsCard: React.FC<{ label: string; value?: string | number }> = ({ - label, - value = 0, -}) => { - const theme = useTheme(); - - if (!label) { - return null; - } - - return ( - - {value} - {label} - - ); -}; - 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, }, }); diff --git a/src/screens/StatsScreen/TimeTab.tsx b/src/screens/StatsScreen/TimeTab.tsx new file mode 100644 index 00000000..3aac5957 --- /dev/null +++ b/src/screens/StatsScreen/TimeTab.tsx @@ -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 = ({ stats, theme }) => { + const [showingNovels, setShowingNovels] = useState(true); + + const timeSpentData = useMemo(() => { + 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 ( + + + + + {item.name} + + + {formatTimeSpent(item.timeSpent)} + + + + ); + } + return ( + + + + {item.name} + + + {formatTimeSpent(item.timeSpent)} + + + + ); + }, + [theme], + ); + + const totalTimeParts = formatTotalTimeParts(stats.totalTimeSpent); + + const formatTwoNumbers = (timeSpent: number) => + timeSpent <= 9 ? `0${timeSpent}` : timeSpent; + + const timeListHeader = useCallback( + () => ( + <> + + + {getString('statsScreen.totalTimeSpent')} + + {totalTimeParts ? ( + + {totalTimeParts.days > 0 && ( + + + {formatTwoNumbers(totalTimeParts.days)} + + + {getString('time.days', { count: totalTimeParts.days }) + .replace(/\d+/, '') + .trim()} + + + )} + {totalTimeParts.hours > 0 && ( + + + {formatTwoNumbers(totalTimeParts.hours)} + + + {getString('time.hours', { count: totalTimeParts.hours }) + .replace(/\d+/, '') + .trim()} + + + )} + + + {formatTwoNumbers(totalTimeParts.minutes)} + + + {getString('time.minutes', { + count: totalTimeParts.minutes, + }) + .replace(/\d+/, '') + .trim()} + + + + ) : ( + + {formatTimeSpent(stats.totalTimeSpent)} + + )} + + + + {showingNovels + ? getString('statsScreen.topNovelsByTimeSpent') + : getString('statsScreen.topCategoriesByTimeSpent')} + + setShowingNovels(!showingNovels)} + accessibilityRole="button" + accessibilityLabel={ + showingNovels + ? getString('statsScreen.showCategories') + : getString('statsScreen.showNovels') + } + /> + + + ), + [stats.totalTimeSpent, theme, showingNovels, totalTimeParts], + ); + + return ( + 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, + }, +}); diff --git a/src/screens/StatsScreen/components/AnimatedHeight.tsx b/src/screens/StatsScreen/components/AnimatedHeight.tsx new file mode 100644 index 00000000..53150068 --- /dev/null +++ b/src/screens/StatsScreen/components/AnimatedHeight.tsx @@ -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; + duration?: number; +} + +const AnimatedHeight: React.FC = ({ + 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 ( + + + {children} + + + ); +}; + +const styles = StyleSheet.create({ + content: { + position: 'absolute', + top: 14, + right: 0, + left: 0, + }, +}); + +export default React.memo(AnimatedHeight); diff --git a/src/screens/StatsScreen/components/ChapterBar.tsx b/src/screens/StatsScreen/components/ChapterBar.tsx new file mode 100644 index 00000000..31f858ef --- /dev/null +++ b/src/screens/StatsScreen/components/ChapterBar.tsx @@ -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 = ({ + read, + total, + downloaded, +}) => { + const theme = useTheme(); + + const readPercent = total > 0 ? Math.min(read / total, 1) : 0; + + return ( + + + {getString('statsScreen.totalChapters')} + + + {total} + + + + + {total > 0 && ( + + + + {getString('statsScreen.readChapters')} + + + {read} + + + + + {getString('statsScreen.downloadedChapters')} + + + {downloaded} + + + + )} + + ); +}; + +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', + }, +}); diff --git a/src/screens/StatsScreen/components/ChartLegend.tsx b/src/screens/StatsScreen/components/ChartLegend.tsx new file mode 100644 index 00000000..8b841f4b --- /dev/null +++ b/src/screens/StatsScreen/components/ChartLegend.tsx @@ -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 = ({ + entries, + highlightedKey, + onEntryPress, + theme, +}) => { + return ( + + {entries.map(entry => { + const isHighlighted = highlightedKey === entry.key; + return ( + onEntryPress(entry.key)} + style={styles.legendRow} + > + + + {entry.label} + + + {entry.value} + + + ); + })} + + ); +}; + +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; diff --git a/src/screens/StatsScreen/components/DonutChart.tsx b/src/screens/StatsScreen/components/DonutChart.tsx new file mode 100644 index 00000000..f5fcd855 --- /dev/null +++ b/src/screens/StatsScreen/components/DonutChart.tsx @@ -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 { + const bases = [ + theme.primary, + theme.primaryContainer, + theme.tertiary, + theme.tertiaryContainer, + theme.secondary, + theme.secondaryContainer, + theme.surface, + theme.surfaceVariant, + theme.onPrimary, + ]; + const result: Record = {}; + 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; + 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(() => { + 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 ( + onPress?.(segKey)}> + + + ); +}; + +export const DonutChart: React.FC = ({ + 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 ( + + + + {segments.map(seg => { + const { key, ...p } = seg; + const isHighlighted = highlightedKey === key; + const props = { + ...p, + segKey: key, + cx, + cy, + radius, + innerRadius, + isHighlighted, + onPress: onSegmentPress, + }; + return ; + })} + + + + {/* Center count */} + + + {total} + + + {centerLabel} + + + + ); +}; + +const styles = StyleSheet.create({ + centerLabel: { + position: 'absolute', + top: 0, + left: 0, + justifyContent: 'center', + alignItems: 'center', + }, + centerCount: { + fontSize: 22, + fontWeight: 'bold', + }, + centerUnit: { + fontSize: 11, + }, +}); diff --git a/src/screens/StatsScreen/components/DonutChartWithLegend.tsx b/src/screens/StatsScreen/components/DonutChartWithLegend.tsx new file mode 100644 index 00000000..5f27017c --- /dev/null +++ b/src/screens/StatsScreen/components/DonutChartWithLegend.tsx @@ -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; + theme: ThemeColors; + centerLabel?: string; + getLabel?: (key: string) => string; +} + +const DonutChartWithLegend: React.FC = ({ + title, + entries, + size = 160, + thickness = 28, + colors, + theme, + centerLabel, + getLabel, +}) => { + const [highlightedKey, setHighlightedKey] = useState( + 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 ( + + + {title} + + + + + + + ); +}; + +const styles = StyleSheet.create({ + header: { + fontWeight: 'bold', + paddingVertical: 16, + }, + donutContainer: { + alignItems: 'center', + marginBottom: 12, + }, +}); + +export default DonutChartWithLegend; diff --git a/src/screens/StatsScreen/components/PluginSection.tsx b/src/screens/StatsScreen/components/PluginSection.tsx new file mode 100644 index 00000000..3267b2db --- /dev/null +++ b/src/screens/StatsScreen/components/PluginSection.tsx @@ -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 = ({ + 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 ( + + setExpanded(e => !e)} + accessibilityRole="button" + style={({ pressed }) => [ + styles.header, + { borderBottomColor: theme.outlineVariant }, + pressed && { opacity: 0.7 }, + ]} + > + + {name} + + + + {count} + + + + + {adaptiveRender === 'light' ? null : ( + + {pluginNovels.map(novel => ( + onNovelPress(novel)} + style={styles.novelRow} + > + + + {novel.name} + + + ))} + + )} + + ); +}; + +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; diff --git a/src/screens/StatsScreen/components/StatsCard.tsx b/src/screens/StatsScreen/components/StatsCard.tsx new file mode 100644 index 00000000..21d58819 --- /dev/null +++ b/src/screens/StatsScreen/components/StatsCard.tsx @@ -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 ( + + {value} + {label} + + ); +}; diff --git a/src/screens/StatsScreen/components/index.ts b/src/screens/StatsScreen/components/index.ts new file mode 100644 index 00000000..519e8b73 --- /dev/null +++ b/src/screens/StatsScreen/components/index.ts @@ -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'; diff --git a/src/screens/StatsScreen/utils.tsx b/src/screens/StatsScreen/utils.tsx new file mode 100644 index 00000000..6b13cd98 --- /dev/null +++ b/src/screens/StatsScreen/utils.tsx @@ -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, +): { name: string; count: number }[] { + const merged: Record = {}; + 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(); + const groups: Map = 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 = 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); +} diff --git a/src/screens/settings/SettingsScreen.tsx b/src/screens/settings/SettingsScreen.tsx index ebebb2cc..2c1dfb22 100644 --- a/src/screens/settings/SettingsScreen.tsx +++ b/src/screens/settings/SettingsScreen.tsx @@ -97,6 +97,16 @@ const SettingsScreen = ({ navigation }: SettingsScreenProps) => { } theme={theme} /> + + navigation.navigate('SettingsStack', { + screen: 'GenreTaxonomy', + }) + } + theme={theme} + /> ); diff --git a/src/screens/settings/SettingsTaxonomyScreen/SettingsTaxonomyScreen.tsx b/src/screens/settings/SettingsTaxonomyScreen/SettingsTaxonomyScreen.tsx new file mode 100644 index 00000000..3d88be40 --- /dev/null +++ b/src/screens/settings/SettingsTaxonomyScreen/SettingsTaxonomyScreen.tsx @@ -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 ( + + + + {/* Parent categories section */} + + + {getString('genreStats.parentCategories')} + + {hasTaxonomy ? ( + taxonomy.map(node => ( + + openDialog({ type: 'editParent', parentName: node.parent }) + } + android_ripple={{ color: theme.rippleColor }} + > + + + + {node.parent} + + + {getString('genreStats.subgenres', { + count: node.children.length, + })} + + + + + setDeleteTarget({ + type: 'parent', + name: node.parent, + }) + } + /> + + + + )) + ) : ( + + )} + openDialog({ type: 'addParent' })} + theme={theme} + /> + + + + {/* Add / Edit Parent Dialog */} + {(dialog.type === 'addParent' || dialog.type === 'editParent') && ( + setDialog({ type: 'none' })} + > + + + {dialog.type === 'addParent' + ? getString('genreStats.addCategory') + : dialog.parentName} + + + + + + {dialog.type === 'editParent' && + (() => { + const node = taxonomy.find( + t => t.parent === dialog.parentName, + ); + if (!node) return null; + return ( + <> + {node.children.length > 0 && ( + item} + renderItem={({ item }) => ( + + + {item} + + + handleDeleteChild(dialog.parentName, item) + } + hitSlop={8} + > + + + + )} + style={styles.childList} + /> + )} + + + + + + ); + })()} + + + setDialog({ type: 'none' })}> + {getString('common.cancel')} + + + {getString('common.ok')} + + + + )} + + + {/* Delete confirmations */} + {deleteTarget?.type === 'parent' && ( + handleDeleteParent(deleteTarget.name)} + onDismiss={() => setDeleteTarget(null)} + /> + )} + + ); +}; + +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, + }, +});