From 808ef1255b904e2a5ce4899a69f5f08e51712361 Mon Sep 17 00:00:00 2001 From: Jesus rodolfo gil Farias Date: Sun, 19 Jul 2026 20:30:31 -0400 Subject: [PATCH] feat: implement domain model, services, and UI components for obra management in catalog --- src/application/catalogo-service.ts | 8 +++ src/domain/obra.ts | 31 +++++++++++ src/domain/types.ts | 3 ++ src/infrastructure/persistence/db.ts | 6 +++ src/ui/components/ObraFormModal.tsx | 78 ++++++++++++++++++++++++++-- src/ui/pages/CatalogoPage.tsx | 8 ++- src/ui/pages/ObraDetallePage.tsx | 31 ++++++++++- 7 files changed, 160 insertions(+), 5 deletions(-) diff --git a/src/application/catalogo-service.ts b/src/application/catalogo-service.ts index 966d231..f19815c 100644 --- a/src/application/catalogo-service.ts +++ b/src/application/catalogo-service.ts @@ -7,6 +7,10 @@ export interface Filtro { tag?: string; // RF-014 estado?: EstadoObra; // RF-014 prioridad?: Prioridad; // RF-014 + autor?: string; + artista?: string; + estadoPublicacion?: string; // EstadoPublicacion + calificacionMin?: number; } /** Casos de uso del catálogo (F-01, F-04). */ @@ -44,6 +48,10 @@ export class CatalogoService { .filter((o) => (filtro.tag ? o.tags.some((t) => t.toLowerCase() === filtro.tag!.toLowerCase()) : true)) .filter((o) => (filtro.estado ? o.estado === filtro.estado : true)) .filter((o) => (filtro.prioridad ? o.prioridad === filtro.prioridad : true)) + .filter((o) => (filtro.autor ? o.autor?.toLowerCase().includes(filtro.autor.toLowerCase()) : true)) + .filter((o) => (filtro.artista ? o.artista?.toLowerCase().includes(filtro.artista.toLowerCase()) : true)) + .filter((o) => (filtro.estadoPublicacion ? o.estadoPublicacion === filtro.estadoPublicacion : true)) + .filter((o) => (filtro.calificacionMin !== undefined ? (o.calificacion ?? 0) >= filtro.calificacionMin : true)) .sort((a, b) => a.titulo.localeCompare(b.titulo, 'es')); } } diff --git a/src/domain/obra.ts b/src/domain/obra.ts index d6e8156..afc6711 100644 --- a/src/domain/obra.ts +++ b/src/domain/obra.ts @@ -6,6 +6,8 @@ import { PRIORIDADES, TIPOS_OBRA, TipoObra, + EstadoPublicacion, + ESTADOS_PUBLICACION, } from './types'; export interface Obra { @@ -18,6 +20,11 @@ export interface Obra { prioridad: Prioridad; url?: string; // URL de lectura actual (cambiable) notas?: string; + autor?: string; + artista?: string; + estadoPublicacion?: EstadoPublicacion; + calificacion?: number; // 0 a 5, paso 0.5 + obrasRelacionadas?: string[]; // IDs de otras obras creadaEn: string; // ISO 8601 actualizadaEn: string; // ISO 8601 } @@ -31,6 +38,11 @@ export interface NuevaObra { prioridad?: Prioridad; url?: string; // URL de lectura actual notas?: string; + autor?: string; + artista?: string; + estadoPublicacion?: EstadoPublicacion; + calificacion?: number; + obrasRelacionadas?: string[]; } const norm = (s: string) => s.trim(); @@ -59,6 +71,15 @@ export function crearObra(input: NuevaObra, id: string, ahora = new Date()): Obr const prioridad = input.prioridad ?? 'media'; // RF-005: por defecto media if (!PRIORIDADES.includes(prioridad)) throw new DomainError(`Prioridad inválida: ${prioridad} (RF-005).`); + if (input.calificacion !== undefined) { + if (input.calificacion < 0 || input.calificacion > 5) throw new DomainError(`La calificación debe estar entre 0 y 5.`); + if (input.calificacion % 0.5 !== 0) throw new DomainError(`La calificación debe ser múltiplo de 0.5.`); + } + + if (input.estadoPublicacion !== undefined && !ESTADOS_PUBLICACION.includes(input.estadoPublicacion)) { + throw new DomainError(`Estado de publicación inválido: ${input.estadoPublicacion}.`); + } + const iso = ahora.toISOString(); const urlRaw = input.url?.trim(); return { @@ -71,6 +92,11 @@ export function crearObra(input: NuevaObra, id: string, ahora = new Date()): Obr prioridad, url: urlRaw || undefined, notas: input.notas?.trim() || undefined, + autor: input.autor?.trim() || undefined, + artista: input.artista?.trim() || undefined, + estadoPublicacion: input.estadoPublicacion, + calificacion: input.calificacion, + obrasRelacionadas: dedup(input.obrasRelacionadas ?? []), creadaEn: iso, actualizadaEn: iso, }; @@ -88,6 +114,11 @@ export function editarObra(obra: Obra, cambios: Partial, ahora = new prioridad: cambios.prioridad ?? obra.prioridad, url: cambios.url !== undefined ? cambios.url : obra.url, notas: cambios.notas ?? obra.notas, + autor: cambios.autor !== undefined ? cambios.autor : obra.autor, + artista: cambios.artista !== undefined ? cambios.artista : obra.artista, + estadoPublicacion: cambios.estadoPublicacion !== undefined ? cambios.estadoPublicacion : obra.estadoPublicacion, + calificacion: cambios.calificacion !== undefined ? cambios.calificacion : obra.calificacion, + obrasRelacionadas: cambios.obrasRelacionadas ?? obra.obrasRelacionadas, }, obra.id, ahora, diff --git a/src/domain/types.ts b/src/domain/types.ts index 8a8adca..f53289c 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -11,6 +11,9 @@ export const PRIORIDADES: Prioridad[] = ['alta', 'media', 'baja']; export type EstadoFuente = 'activa' | 'caida' | 'sin_verificar'; +export type EstadoPublicacion = 'emision' | 'finalizado' | 'pausado' | 'cancelado'; +export const ESTADOS_PUBLICACION: EstadoPublicacion[] = ['emision', 'finalizado', 'pausado', 'cancelado']; + /** Error de violación de una invariante de dominio. */ export class DomainError extends Error { constructor(message: string) { diff --git a/src/infrastructure/persistence/db.ts b/src/infrastructure/persistence/db.ts index dc7d2fc..ed500da 100644 --- a/src/infrastructure/persistence/db.ts +++ b/src/infrastructure/persistence/db.ts @@ -25,6 +25,12 @@ export class CapMarkDB extends Dexie { fuentes: 'id, obraId, esPrincipal', progreso: 'id, obraId, registradoEn', }); + // v3: nuevos campos opcionales autor, artista, estadoPublicacion, calificacion, obrasRelacionadas + this.version(3).stores({ + obras: 'id, titulo, tipo, estado, prioridad, actualizadaEn', + fuentes: 'id, obraId, esPrincipal', + progreso: 'id, obraId, registradoEn', + }); } } diff --git a/src/ui/components/ObraFormModal.tsx b/src/ui/components/ObraFormModal.tsx index ad3f541..9806140 100644 --- a/src/ui/components/ObraFormModal.tsx +++ b/src/ui/components/ObraFormModal.tsx @@ -6,7 +6,8 @@ import { import { codeSlash, createOutline } from 'ionicons/icons'; import { NuevaObra } from '@application/ports'; import { Obra } from '@domain/obra'; -import { ESTADOS_OBRA, PRIORIDADES, TIPOS_OBRA } from '@domain/types'; +import { ESTADOS_OBRA, PRIORIDADES, TIPOS_OBRA, ESTADOS_PUBLICACION } from '@domain/types'; +import { container } from '@infrastructure/container'; interface Props { isOpen: boolean; @@ -28,6 +29,13 @@ export default function ObraFormModal({ isOpen, obra, onClose, onSave }: Props) const [jsonText, setJsonText] = useState(''); const [jsonError, setJsonError] = useState(null); const [error, setError] = useState(null); + const [todasObras, setTodasObras] = useState([]); + + useEffect(() => { + if (isOpen) { + container.catalogo.buscar({}).then(setTodasObras); + } + }, [isOpen]); useEffect(() => { if (obra) { @@ -38,13 +46,18 @@ export default function ObraFormModal({ isOpen, obra, onClose, onSave }: Props) prioridad: obra.prioridad, url: obra.url ?? '', notas: obra.notas, + autor: obra.autor, + artista: obra.artista, + estadoPublicacion: obra.estadoPublicacion, + calificacion: obra.calificacion, + obrasRelacionadas: obra.obrasRelacionadas, }; setForm(base); setAlias(obra.nombresAlternativos.join(', ')); setTags(obra.tags.join(', ')); setUrl(obra.url ?? ''); setJsonText(JSON.stringify( - { ...base, nombresAlternativos: obra.nombresAlternativos, tags: obra.tags }, + { ...base, nombresAlternativos: obra.nombresAlternativos, tags: obra.tags, obrasRelacionadas: obra.obrasRelacionadas }, null, 2, )); @@ -99,7 +112,7 @@ export default function ObraFormModal({ isOpen, obra, onClose, onSave }: Props) setJsonText(raw); setJsonError(null); try { - const parsed = JSON.parse(raw) as Partial & { nombresAlternativos?: string[]; tags?: string[] }; + const parsed = JSON.parse(raw) as Partial & { nombresAlternativos?: string[]; tags?: string[]; obrasRelacionadas?: string[] }; setForm({ titulo: parsed.titulo ?? '', tipo: parsed.tipo ?? 'manga', @@ -107,6 +120,11 @@ export default function ObraFormModal({ isOpen, obra, onClose, onSave }: Props) prioridad: parsed.prioridad ?? 'media', url: parsed.url ?? '', notas: parsed.notas, + autor: parsed.autor, + artista: parsed.artista, + estadoPublicacion: parsed.estadoPublicacion, + calificacion: parsed.calificacion, + obrasRelacionadas: parsed.obrasRelacionadas, }); setAlias((parsed.nombresAlternativos ?? []).join(', ')); setTags((parsed.tags ?? []).join(', ')); @@ -180,6 +198,14 @@ export default function ObraFormModal({ isOpen, obra, onClose, onSave }: Props) + + Estado Publicación + setField({ estadoPublicacion: e.detail.value || undefined })}> + Desconocido + {ESTADOS_PUBLICACION.map((s) => {s})} + + + Prioridad setField({ prioridad: e.detail.value })}> @@ -215,6 +241,52 @@ export default function ObraFormModal({ isOpen, obra, onClose, onSave }: Props) /> + + Autor + setField({ autor: e.detail.value ?? undefined })} + placeholder="Ej. Chugong" + /> + + + + Artista + setField({ artista: e.detail.value ?? undefined })} + placeholder="Ej. DUBU" + /> + + + + Calificación (0 a 5) + { + const val = e.detail.value; + setField({ calificacion: val ? parseFloat(val) : undefined }); + }} + /> + + + + Obras Relacionadas + setField({ obrasRelacionadas: e.detail.value })} + > + {todasObras.filter(o => o.id !== obra?.id).map((o) => ( + {o.titulo} + ))} + + + Notas Todos {tags.map((t) => {t})} + aplicar({ estadoPublicacion: e.detail.value || undefined })}> + Cualquiera + {ESTADOS_PUBLICACION.map((s) => {s})} + @@ -163,6 +167,8 @@ export default function CatalogoPage() {

{obra.titulo}

{obra.tipo} · Cap. {capFmt(capitulo)} · Última: {fechaCorta(ultima)} + {obra.calificacion ? ` · ⭐ ${obra.calificacion}` : ''} + {obra.autor ? ` · 👤 ${obra.autor}` : ''}

{obra.tags.slice(0, 3).map((t) => {t})} diff --git a/src/ui/pages/ObraDetallePage.tsx b/src/ui/pages/ObraDetallePage.tsx index e4c5124..a80484c 100644 --- a/src/ui/pages/ObraDetallePage.tsx +++ b/src/ui/pages/ObraDetallePage.tsx @@ -27,12 +27,20 @@ export default function ObraDetallePage() { const [obra, setObra] = useState(); const [fuentes, setFuentes] = useState([]); const [historial, setHistorial] = useState([]); + const [relacionadas, setRelacionadas] = useState([]); const [editar, setEditar] = useState(false); const cargar = async () => { - setObra(await container.catalogo.obtener(id)); + const o = await container.catalogo.obtener(id); + setObra(o); setFuentes(await container.fuentes.listar(id)); setHistorial(await container.progreso.historial(id)); + if (o?.obrasRelacionadas?.length) { + const rels = await Promise.all(o.obrasRelacionadas.map((rid) => container.catalogo.obtener(rid))); + setRelacionadas(rels.filter(Boolean) as Obra[]); + } else { + setRelacionadas([]); + } }; useIonViewWillEnter(() => { void cargar(); }); @@ -188,6 +196,27 @@ export default function ObraDetallePage() {

También: {obra.nombresAlternativos.join(' · ')}

)}
{obra.tags.map((t) => {t})}
+ +
+ {obra.autor &&

Autor: {obra.autor}

} + {obra.artista &&

Artista: {obra.artista}

} + {obra.estadoPublicacion &&

Publicación: {obra.estadoPublicacion}

} + {obra.calificacion !== undefined &&

Calificación: ⭐ {obra.calificacion}

} +
+ + {relacionadas.length > 0 && ( +
+ Adaptaciones / Relacionadas: +
+ {relacionadas.map(rel => ( + history.push(`/obra/${rel.id}`)}> + {rel.titulo} + + ))} +
+
+ )} + {obra.notas &&

{obra.notas}

} {/* Progreso (RF-011/012, RNF-002) */}