From 2bac1280ec7c918ecd23b78343ab0333a38a9256 Mon Sep 17 00:00:00 2001 From: Surya Ahuja Date: Sun, 19 Apr 2026 18:01:02 -0700 Subject: [PATCH] feat: Add color marking for read/last-read chapters in library Add visual indicators in the chapter list so users can see which chapters have been read and which one was most recently read. Changes: - Backend: Expose updated_at in chapter list endpoints (ChapterService.php) so the frontend can determine the last-read chapter by timestamp. - Library chapter list (BookChapters.vue): Add item-class callback that applies 'chapter-read' or 'chapter-last-read' CSS class to table rows. Add a check-circle icon next to chapter names for read chapters (green for last-read, grey for others). Add lastReadChapterId computed property that finds the chapter with the highest updated_at among read chapters. - Reader chapter list (TextReaderChapterList.vue): Apply matching visual indicators in the in-reader chapter list dialog. - Styling (BookChapters.scss, TextReaderChapterList.scss): Add chapter-read class (4px muted green left border) and chapter-last-read class (4px green left border + tinted background). - Theme colors (themes.js): Add chapterReadBorder and chapterLastReadBackground color variables for light, dark, and eink themes. - Testing: Add BookFactory, ChapterFactory, and ChapterReadTrackingTest with 4 feature tests covering read_count and updated_at in API responses, read count increment on finish, and timestamp update on finish. Enable SQLite in-memory DB in phpunit.xml for fast isolated tests. --- app/Services/ChapterService.php | 56 ++++----- database/factories/BookFactory.php | 25 ++++ database/factories/ChapterFactory.php | 34 ++++++ phpunit.xml | 4 +- .../js/components/Library/BookChapters.vue | 63 ++++++++-- .../TextReader/TextReaderChapterList.vue | 51 +++++++- resources/js/themes.js | 16 ++- resources/sass/Library/BookChapters.scss | 11 +- .../TextReader/TextReaderChapterList.scss | 11 +- tests/Feature/ChapterReadTrackingTest.php | 112 ++++++++++++++++++ 10 files changed, 328 insertions(+), 55 deletions(-) create mode 100644 database/factories/BookFactory.php create mode 100644 database/factories/ChapterFactory.php create mode 100644 tests/Feature/ChapterReadTrackingTest.php diff --git a/app/Services/ChapterService.php b/app/Services/ChapterService.php index 7300c5e4..4b45379c 100644 --- a/app/Services/ChapterService.php +++ b/app/Services/ChapterService.php @@ -27,13 +27,13 @@ public function getChaptersForBook($userId, $bookId) { ::where('id', $bookId) ->where('user_id', $userId) ->first(); - + if (!$book) { throw new \Exception('Book does not exist, or it belongs to a different user.'); } $chapters = Chapter - ::select(['id', 'name', 'read_count', 'word_count', 'unique_word_ids', 'processing_status']) + ::select(['id', 'name', 'read_count', 'word_count', 'unique_word_ids', 'processing_status', 'updated_at']) ->where('book_id', $bookId) ->where('user_id', $userId) ->get(); @@ -54,7 +54,7 @@ public function getChaptersForBook($userId, $bookId) { $chapters[$i]->wordCount->highlighted = -1; $chapters[$i]->wordCount->new = -1; } - + $data = new \stdClass(); $data->book = $book; $data->chapters = $chapters; @@ -67,7 +67,7 @@ public function getChaptersBookCount($userId, $userUuid, $bookId) { ::where('id', $bookId) ->where('user_id', $userId) ->first(); - + if (!$book) { throw new \Exception('Book does not exist, or it belongs to a different user.'); } @@ -102,10 +102,10 @@ public function getChaptersBookCount($userId, $userUuid, $bookId) { $chaptersWithWordCounts = []; } } - + return true; } - + public function getChapterForEditor($userId, $chapterId) { $chapter = Chapter:: select(['name', 'raw_text', 'type']) @@ -118,7 +118,7 @@ public function getChapterForEditor($userId, $chapterId) { } $chapter->raw_text = str_replace(" NEWLINE \r\n", "\r\n", $chapter->raw_text); - + return $chapter; } @@ -129,7 +129,7 @@ public function getChapterForReader($userId, $language, $languagesWithoutSpaces, ->where('language', $language) ->where('processing_status', ChapterProcessingStatusEnum::PROCESSED->value) ->first(); - + if (!$chapter) { throw new \Exception('Chapter could not be found.'); } @@ -140,7 +140,7 @@ public function getChapterForReader($userId, $language, $languagesWithoutSpaces, ->first(); $chapters = Chapter - ::select(['id', 'name', 'read_count', 'word_count', 'unique_word_ids', 'processing_status']) + ::select(['id', 'name', 'read_count', 'word_count', 'unique_word_ids', 'processing_status', 'updated_at']) ->where('user_id', $userId) ->where('book_id', $book->id) ->get(); @@ -163,7 +163,7 @@ public function getChapterForReader($userId, $language, $languagesWithoutSpaces, $chapters[$i]->wordCount->known = -1; $chapters[$i]->wordCount->highlighted = -1; $chapters[$i]->wordCount->new = -1; - + if ($chapters[$i]->processing_status !== ChapterProcessingStatusEnum::PROCESSED->value) { continue; } @@ -191,7 +191,7 @@ public function getChapterForReader($userId, $language, $languagesWithoutSpaces, $data->languageSpaces = !in_array($language, $languagesWithoutSpaces, true); $data->chapters = $chapters; $data->wordCount = $chapter->word_count; - + return $data; } @@ -203,7 +203,7 @@ public function finishChapter($userId, $chapterId, $autoMoveWordsToKnown, $uniqu foreach ($uniqueWords as $uniqueWordData) { $saveData = []; $saveData['read_count'] = $uniqueWordData->read_count; - + if ($uniqueWordData->stage == 2) { $saveData['stage'] = 0; } @@ -266,7 +266,7 @@ public function finishChapter($userId, $chapterId, $autoMoveWordsToKnown, $uniqu } $word->setStage($word->stage + 1); - $word->save(); + $word->save(); } return true; @@ -298,14 +298,14 @@ public function createChapter($userId, $userUuid, $bookId, $chapterName, $chapte $chapter->save(); $this->updateChapter($userId, $userUuid, $chapter->id, $chapter->name, $chapterText); - + return true; } // updates the name and text of a chapter public function updateChapter($userId, $userUuid, $chapterId, $chapterName, $chapterText) { DB::disableQueryLog(); - + // retrieve chapter $chapter = Chapter ::where('id', $chapterId) @@ -315,15 +315,15 @@ public function updateChapter($userId, $userUuid, $chapterId, $chapterName, $cha if (!$chapter) { throw new \Exception('Chapter does not exist, or it belongs to a different user.'); } - + // update chapter data $chapter->raw_text = $chapterText; $chapter->name = $chapterName; $chapter->processing_status = ChapterProcessingStatusEnum::UNPROCESSED->value; $chapter->save(); - + \App\Jobs\ProcessChapter::dispatch($userId, $userUuid, $chapter->id, $chapter->language); - + return true; } @@ -343,10 +343,10 @@ public function processChapterText($userId, $chapterId) { if (!$chapter) { throw new \Exception('Chapter does not exist, or it belongs to a different user.'); } - + // process text - $textBlock = new TextBlockService($userId, $chapter->language); - + $textBlock = new TextBlockService($userId, $chapter->language); + if ($chapter->type == 'text') { $textBlock->rawText = $chapter->raw_text; $textBlock->tokenizeRawText(); @@ -355,7 +355,7 @@ public function processChapterText($userId, $chapterId) { $textBlock->rawText = $chapter->raw_text; $timeStamps = $textBlock->tokenizeRawSubtitles(); } - + $textBlock->processTokenizedWords(); $textBlock->collectUniqueWords(); $textBlock->updateAllPhraseIds(); @@ -379,15 +379,15 @@ public function processChapterText($userId, $chapterId) { $chapter->subtitle_timestamps = json_encode($timeStamps); $chapter->processing_status = ChapterProcessingStatusEnum::PROCESSED->value; $chapter->save(); - - $bookId = $chapter->book_id; + + $bookId = $chapter->book_id; }); - + $this->bookService->updateBookWordCount($userId, $bookId); } public function deleteChapter($userId, $chapterId) { - + // retrieve chapter $chapter = Chapter ::where('user_id', $userId) @@ -409,7 +409,7 @@ public function deleteChapter($userId, $chapterId) { } public function retryFailedChapters($userId, $userUuid, $bookId) { - + $chapters = Chapter ::where('user_id', $userId) ->where('book_id', $bookId) @@ -428,4 +428,4 @@ public function retryFailedChapters($userId, $userUuid, $bookId) { return true; } -} \ No newline at end of file +} diff --git a/database/factories/BookFactory.php b/database/factories/BookFactory.php new file mode 100644 index 00000000..4bfe2a1d --- /dev/null +++ b/database/factories/BookFactory.php @@ -0,0 +1,25 @@ + + */ +class BookFactory extends Factory +{ + protected $model = Book::class; + + public function definition(): array + { + return [ + 'user_id' => 1, + 'name' => fake()->sentence(3), + 'cover_image' => '', + 'language' => 'english', + 'word_count' => 0, + ]; + } +} diff --git a/database/factories/ChapterFactory.php b/database/factories/ChapterFactory.php new file mode 100644 index 00000000..64b8b6eb --- /dev/null +++ b/database/factories/ChapterFactory.php @@ -0,0 +1,34 @@ + + */ +class ChapterFactory extends Factory +{ + protected $model = Chapter::class; + + public function definition(): array + { + return [ + 'user_id' => 1, + 'book_id' => 1, + 'name' => fake()->sentence(3), + 'read_count' => 0, + 'word_count' => fake()->numberBetween(50, 500), + 'language' => 'english', + 'raw_text' => fake()->paragraph(), + 'processed_text' => '', + 'unique_words' => '[]', + 'unique_word_ids' => '[]', + 'type' => 'text', + 'subtitle_timestamps' => '', + 'processing_status' => ChapterProcessingStatusEnum::PROCESSED->value, + ]; + } +} diff --git a/phpunit.xml b/phpunit.xml index 506b9a38..61c031c4 100755 --- a/phpunit.xml +++ b/phpunit.xml @@ -22,8 +22,8 @@ - - + + diff --git a/resources/js/components/Library/BookChapters.vue b/resources/js/components/Library/BookChapters.vue index 55edd553..a5cb19e4 100644 --- a/resources/js/components/Library/BookChapters.vue +++ b/resources/js/components/Library/BookChapters.vue @@ -3,14 +3,14 @@ - + - - + + + +