Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/application/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface Repositorio {
// Progreso
agregarProgreso(entry: ProgresoEntry): Promise<void>;
listarProgreso(obraId: string): Promise<ProgresoEntry[]>;
eliminarProgreso(id: string): Promise<void>;
}

/** RF-017 / RNF-008: verifica del lado servidor si una URL responde (≤ 5 s, no bloqueante). */
Expand Down
27 changes: 27 additions & 0 deletions src/application/progreso-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,31 @@ export class ProgresoService {
async actual(obraId: string): Promise<ProgresoEntry | undefined> {
return puntoActual(await this.repo.listarProgreso(obraId));
}

/** Fija un capítulo y auto-rellena todos los capítulos enteros anteriores si no existen. */
async fijar(obraId: string, input: NuevoProgreso): Promise<ProgresoEntry> {
const target = input.capitulo;
const historialActual = await this.repo.listarProgreso(obraId);
const setCapitulos = new Set(historialActual.map((e) => e.capitulo));

// Rellenar enteros anteriores (ej: si target es 50, rellenar 1..49)
if (target > 1) {
const tope = Math.floor(target);
// Iteramos creando entradas con la hora actual ligeramente desfasada
// (aunque al final todos tendrán tiempos similares, el último será el target)
for (let i = 1; i <= tope; i++) {
if (!setCapitulos.has(i) && i !== target) {
const entry = registrarProgreso(obraId, { capitulo: i }, this.id.nuevo(), this.reloj.ahora());
await this.repo.agregarProgreso(entry);
}
}
}

// Registrar finalmente el capítulo deseado para que quede como el más reciente
return this.registrar(obraId, input);
}

async eliminar(id: string): Promise<void> {
await this.repo.eliminarProgreso(id);
}
}
4 changes: 4 additions & 0 deletions src/infrastructure/persistence/dexie-repositorio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,8 @@ export class DexieRepositorio implements Repositorio {
async listarProgreso(obraId: string): Promise<ProgresoEntry[]> {
return this.db.progreso.where('obraId').equals(obraId).toArray();
}

async eliminarProgreso(id: string): Promise<void> {
await this.db.progreso.delete(id);
}
}
9 changes: 9 additions & 0 deletions src/infrastructure/persistence/memory-repositorio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,13 @@ export class MemoryRepositorio implements Repositorio {
async listarProgreso(obraId: string): Promise<ProgresoEntry[]> {
return [...(this.progreso.get(obraId) ?? [])];
}
async eliminarProgreso(id: string): Promise<void> {
for (const [obraId, entries] of this.progreso.entries()) {
const filtered = entries.filter((e) => e.id !== id);
if (filtered.length !== entries.length) {
this.progreso.set(obraId, filtered);
break;
}
}
}
}
36 changes: 35 additions & 1 deletion src/ui/pages/CatalogoPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react';
import {
IonBadge, IonButton, IonButtons, IonChip, IonContent, IonFab, IonFabButton, IonHeader,
IonIcon, IonItem, IonLabel, IonList, IonNote, IonPage, IonSearchbar, IonSelect,
IonSelectOption, IonTitle, IonToolbar, useIonViewWillEnter,
IonSelectOption, IonTitle, IonToolbar, useIonViewWillEnter, IonModal,
} from '@ionic/react';
import { add, cloudOutline, downloadOutline, folderOpenOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
Expand All @@ -26,6 +26,7 @@ export default function CatalogoPage() {
const [total, setTotal] = useState(0);
const [modal, setModal] = useState(false);
const [syncPanel, setSyncPanel] = useState(false);
const [welcomeModal, setWelcomeModal] = useState(false);

const recargar = useCallback(async (f: Filtro) => {
// Universo de tags disponibles (a partir del catálogo completo) para el filtro RF-014.
Expand All @@ -41,6 +42,9 @@ export default function CatalogoPage() {
}),
);
setFilas(filas);
if (todas.length === 0) {
setWelcomeModal(true);
}
}, []);

useIonViewWillEnter(() => { void recargar(filtro); });
Expand Down Expand Up @@ -140,6 +144,9 @@ export default function CatalogoPage() {
{total === 0 ? (
<>
<p className="muted">Tu catálogo está vacío.</p>
<IonButton fill="outline" onClick={() => setWelcomeModal(true)}>
Ver bienvenida
</IonButton>
<IonButton fill="outline" onClick={async () => { await sembrarDemo(); await recargar(filtro); }}>
Cargar datos de ejemplo
</IonButton>
Expand Down Expand Up @@ -192,6 +199,33 @@ export default function CatalogoPage() {
onClose={() => setSyncPanel(false)}
onSyncCompleto={() => void recargar(filtro)}
/>

<IonModal isOpen={welcomeModal} onDidDismiss={() => setWelcomeModal(false)}>
<IonHeader>
<IonToolbar>
<IonTitle>¡Bienvenido a CapMark!</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div className="ion-text-center" style={{ marginTop: '20px' }}>
<h2 style={{ marginBottom: '16px' }}>Tu gestor de lecturas personal</h2>
<p style={{ fontSize: '1.1em', lineHeight: '1.5' }}>
CapMark está diseñado para ayudarte a gestionar las obras (mangas, webtoons, novelas) que llevas en distintas páginas de forma sencilla.
</p>
<p style={{ fontSize: '1.1em', lineHeight: '1.5', marginTop: '16px' }}>
<strong>100% Autoalojado:</strong> Todos tus datos se guardan en tu propio dispositivo. Nada se comparte con terceros sin tu permiso. Si deseas, puedes sincronizar tu progreso usando tu propio Google Drive.
</p>
<div style={{ marginTop: '30px' }}>
<IonButton expand="block" onClick={() => { setWelcomeModal(false); setModal(true); }}>
Agregar mi primera obra
</IonButton>
<IonButton expand="block" fill="outline" onClick={() => setWelcomeModal(false)} style={{ marginTop: '10px' }}>
Explorar la app
</IonButton>
</div>
</div>
</IonContent>
</IonModal>
</IonPage>
);
}
18 changes: 16 additions & 2 deletions src/ui/pages/ObraDetallePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export default function ObraDetallePage() {
{
text: 'Guardar',
handler: async (d) => {
await container.progreso.registrar(id, { capitulo: Number.parseFloat(d.capitulo), punto: d.punto });
await container.progreso.fijar(id, { capitulo: Number.parseFloat(d.capitulo), punto: d.punto });
await cargar();
},
},
Expand Down Expand Up @@ -140,6 +140,11 @@ export default function ObraDetallePage() {
const marcarPrincipal = async (f: Fuente) => setFuentes(await container.fuentes.marcarPrincipal(id, f.id));
const eliminarFuente = async (f: Fuente) => setFuentes(await container.fuentes.eliminar(id, f.id));
const abrir = (f: Fuente) => window.open(f.url, '_blank', 'noopener');
const eliminarProgreso = async (progresoId: string) => {
await container.progreso.eliminar(progresoId);
toast({ message: 'Registro de progreso eliminado', duration: 1500 });
await cargar();
};

if (!obra) {
return (
Expand Down Expand Up @@ -244,7 +249,16 @@ export default function ObraDetallePage() {
<h3 className="cap-actual">Cap. {capFmt(h.capitulo)}</h3>
{h.punto && <p className="muted">{h.punto}</p>}
</IonLabel>
<IonNote slot="end">{fechaCorta(h.registradoEn)}</IonNote>
<IonNote slot="end" style={{ marginRight: '8px' }}>{fechaCorta(h.registradoEn)}</IonNote>
<IonButton
slot="end"
color="danger"
fill="clear"
onClick={() => eliminarProgreso(h.id)}
title="Deshacer (Eliminar registro)"
>
<IonIcon slot="icon-only" icon={trashOutline} />
</IonButton>
</IonItem>
))}
</IonList>
Expand Down
Loading