mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(books): add read status values across schema, types, and validation
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user