feat(books): add read status values across schema, types, and validation

This commit is contained in:
neon
2026-03-29 16:56:57 -06:00
parent e2d0789851
commit 400e5fabc9
7 changed files with 32 additions and 10 deletions
@@ -1,5 +1,5 @@
import { api } from '@/lib/api'
import { Book, BookCheck, BookOpen, BookX } from 'lucide-vue-next'
import { Book, BookCheck, BookMarked, BookOpen, BookX, Pause, RotateCcw, ScanLine } from 'lucide-vue-next'
import type { ReadStatus } from '@projectx/types'
export type StatusOption = {
@@ -9,23 +9,35 @@ export type StatusOption = {
export const STATUS_OPTIONS: StatusOption[] = [
{ value: 'unread', label: 'Unread' },
{ value: 'want_to_read', label: 'Want to read' },
{ value: 'reading', label: 'Reading' },
{ value: 'on_hold', label: 'On hold' },
{ value: 'rereading', label: 'Re-reading' },
{ value: 'read', label: 'Read' },
{ value: 'skimmed', label: 'Skimmed' },
{ value: 'abandoned', label: 'Abandoned' },
]
export const STATUS_ICONS: Record<ReadStatus, unknown> = {
unread: Book,
want_to_read: BookMarked,
reading: BookOpen,
on_hold: Pause,
rereading: RotateCcw,
read: BookCheck,
skimmed: ScanLine,
abandoned: BookX,
}
export const STATUS_COLORS: Record<ReadStatus, string> = {
unread: 'text-muted-foreground',
want_to_read: 'text-violet-500',
reading: 'text-blue-500',
on_hold: 'text-amber-500',
rereading: 'text-fuchsia-500',
read: 'text-emerald-500',
abandoned: 'text-orange-400',
skimmed: 'text-cyan-500',
abandoned: 'text-rose-400',
}
export function useBookStatus() {
@@ -16,7 +16,7 @@ const OVERLAY_OPTIONS: { key: CardOverlayKey; label: string; hint: string }[] =
{ key: 'format', label: 'File format', hint: 'Color-coded EPUB, PDF, CBZ badge at bottom-right' },
{ key: 'rating', label: 'Rating dots', hint: '5-dot rating indicator at bottom-left' },
{ key: 'new', label: 'New indicator', hint: 'Accent dot on books added in the last 7 days' },
{ key: 'read-status', label: 'Read status', hint: 'Color icon showing reading, read, or abandoned status' },
{ key: 'read-status', label: 'Read status', hint: 'Color icon showing the current reading status' },
]
function toggleOverlay(key: CardOverlayKey) {
+9 -1
View File
@@ -10,7 +10,15 @@ export function isAudioFormat(format: string): boolean {
return AUDIO_FORMATS.has(format.toLowerCase());
}
export type ReadStatus = "unread" | "reading" | "read" | "abandoned";
export type ReadStatus =
| "unread"
| "want_to_read"
| "reading"
| "on_hold"
| "rereading"
| "read"
| "skimmed"
| "abandoned";
export type ReadStatusSource = "auto" | "manual";
export type UserBookStatus = {
+1 -1
View File
@@ -13,7 +13,7 @@ export const userBookStatus = pgTable(
bookId: integer('book_id')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
// 'unread' | 'reading' | 'read' | 'abandoned'
// 'unread' | 'want_to_read' | 'reading' | 'on_hold' | 'rereading' | 'read' | 'skimmed' | 'abandoned'
status: varchar('status', { length: 20 }).notNull().default('unread'),
// 'auto' (derived from progress) | 'manual' (user-set; never auto-overridden)
source: varchar('source', { length: 10 }).notNull().default('auto'),
@@ -2,6 +2,6 @@ import { IsIn } from 'class-validator';
import type { ReadStatus } from '@projectx/types';
export class SetStatusDto {
@IsIn(['unread', 'reading', 'read', 'abandoned'])
@IsIn(['unread', 'want_to_read', 'reading', 'on_hold', 'rereading', 'read', 'skimmed', 'abandoned'])
status!: ReadStatus;
}
@@ -8,6 +8,8 @@ import { userBookStatus } from '../../db/schema';
import type { ReadStatus, ReadStatusSource } from '@projectx/types';
type Db = NodePgDatabase<typeof schema>;
const STARTED_FROM_ACTIVE_STATUSES: ReadStatus[] = ['reading', 'on_hold', 'rereading'];
const RESET_FINISHED_AT_STATUSES: ReadStatus[] = ['unread', 'want_to_read', 'reading', 'on_hold', 'rereading', 'skimmed'];
@Injectable()
export class UserBookStatusRepository {
@@ -40,8 +42,8 @@ export class UserBookStatusRepository {
): Promise<void> {
const row = existing !== undefined ? existing : await this.findOne(userId, bookId);
const startedAt = row?.startedAt ?? (status === 'reading' ? now : null);
const finishedAt = status === 'read' ? now : status === 'unread' || status === 'reading' ? null : (row?.finishedAt ?? null);
const startedAt = row?.startedAt ?? (STARTED_FROM_ACTIVE_STATUSES.includes(status) ? now : null);
const finishedAt = status === 'read' ? now : RESET_FINISHED_AT_STATUSES.includes(status) ? null : (row?.finishedAt ?? null);
await this.db
.insert(userBookStatus)
@@ -103,8 +103,8 @@ export class UserStatisticsRepository {
const [statusRow] = await this.db
.select({
trackedBooks: sql<number>`count(*)::int`,
startedBooks: sql<number>`count(*) filter (where ${userBookStatus.status} in ('reading', 'read', 'abandoned'))::int`,
inProgressBooks: sql<number>`count(*) filter (where ${userBookStatus.status} = 'reading')::int`,
startedBooks: sql<number>`count(*) filter (where ${userBookStatus.status} in ('reading', 'on_hold', 'rereading', 'read', 'skimmed', 'abandoned'))::int`,
inProgressBooks: sql<number>`count(*) filter (where ${userBookStatus.status} in ('reading', 'on_hold', 'rereading'))::int`,
completedBooks: sql<number>`count(*) filter (where ${userBookStatus.status} = 'read')::int`,
})
.from(userBookStatus)