Files
ORCID2SWORD/frontend/src/pages/DashboardPage.jsx
T
Alexis 58f164b036 feat(download-tracking): implementar seguimiento de descargas por usuario en el dashboard
Se añaden funciones para marcar publicaciones y resultados de grupo como descargados en los componentes DashboardPage y GroupResultsPage. Se optimiza la lógica de carga de publicaciones para incluir un control de estado que evita la descarga innecesaria. Además, se actualizan los mocks de publicaciones para reflejar el estado de descarga. Se mejora la presentación del texto en el componente PublicationsTable.
2026-06-03 12:27:57 +02:00

375 lines
12 KiB
React

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useParams, Navigate, Link } from "react-router-dom";
import { toast } from "sonner";
import { AppHeader } from "../components/layout/AppHeader";
import Footer from "../components/layout/Footer";
import { ResearcherCard } from "../components/dashboard/ResearcherCard";
import { StatsRow } from "../components/dashboard/StatsRow";
import { PublicationsTable } from "../components/dashboard/PublicationsTable";
import { ExportDropdown } from "../components/dashboard/ExportDropdown";
import { SyncButton } from "../components/dashboard/SyncButton";
import { ArrowLeftIcon } from "../components/ui/Icons";
import {
downloadExport,
searchResearcher,
syncResearcher,
} from "../services/api";
import { isValidOrcid } from "../utils/orcid";
import {
DEFAULT_EXPORT_DESTINATION,
swordXmlFilename,
} from "../utils/exportProfiles";
import { useAuth } from "../contexts/AuthContext";
import {
markPublicationsAsDownloaded,
publicationsNeedDownloadFlags,
} from "../utils/downloadTracking";
const SUCCESS_FLASH_MS = 3000;
/** Minimum gap between sync requests (protects backend + avoids toast spam). */
const SYNC_COOLDOWN_MS = 5000;
const SYNC_TOAST_ID = "researcher-sync";
/** Minimum gap between export requests (protects backend + avoids toast spam). */
const EXPORT_COOLDOWN_MS = 5000;
const EXPORT_TOAST_ID = "researcher-export";
/**
* Researcher detail page. Owns:
* - Carga inicial vía `searchResearcher`. Si llegamos desde la landing
* usamos el bundle ya cargado en `location.state` para evitar
* duplicar la petición.
* - Re-sync manual (POST + actualización de estado in-place + toast).
* - Exportación SWORD/ZIP:
* · Si hay selección manual → exporta esos IDs.
* · Si el usuario está autenticado y sin selección → exporta solo
* los IDs con downloaded_by_me=false ("lo nuevo").
* · Si no está autenticado y sin selección → exporta todo.
*/
export function DashboardPage() {
const { orcid } = useParams();
const location = useLocation();
const { isAuthenticated } = useAuth();
const initialBundleRef = useRef(location.state?.bundle ?? null);
const consumedInitialBundleRef = useRef(false);
const initialBundle = initialBundleRef.current;
const [researcher, setResearcher] = useState(initialBundle?.researcher ?? null);
const [publications, setPublications] = useState(
initialBundle?.publications ?? [],
);
const [pubsLoading, setPubsLoading] = useState(!initialBundle);
const [pubsError, setPubsError] = useState(null);
const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success
const [syncCooldownActive, setSyncCooldownActive] = useState(false);
const syncInFlightRef = useRef(false);
const syncCooldownUntilRef = useRef(0);
const syncCooldownTimerRef = useRef(null);
const [exportingFormat, setExportingFormat] = useState(null);
const [exportCooldownActive, setExportCooldownActive] = useState(false);
const exportInFlightRef = useRef(false);
const exportCooldownUntilRef = useRef(0);
const exportCooldownTimerRef = useRef(null);
const [exportDestination, setExportDestination] = useState(
DEFAULT_EXPORT_DESTINATION,
);
const [selectedIds, setSelectedIds] = useState(() => new Set());
// IDs de publicaciones que el usuario no ha descargado todavía
const newPublicationIds = useMemo(
() =>
isAuthenticated
? publications
.filter((p) => p.downloaded_by_me === false)
.map((p) => p.id)
: [],
[publications, isAuthenticated],
);
const loadBundle = useCallback(
async (signal) => {
setPubsLoading(true);
setPubsError(null);
try {
const bundle = await searchResearcher(orcid, { signal });
if (signal?.aborted) return;
setResearcher(bundle.researcher);
setPublications(bundle.publications);
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const alive = new Set(bundle.publications.map((p) => p.id));
const next = new Set();
for (const id of prev) if (alive.has(id)) next.add(id);
return next.size === prev.size ? prev : next;
});
} catch (err) {
if (signal?.aborted) return;
setPubsError(err);
toast.error("No se pudo cargar el investigador", {
description: err?.message ?? "Error desconocido.",
});
} finally {
if (!signal?.aborted) setPubsLoading(false);
}
},
[orcid],
);
useEffect(() => {
if (!isValidOrcid(orcid)) return;
const cachedBundle = initialBundleRef.current;
if (cachedBundle && !consumedInitialBundleRef.current) {
consumedInitialBundleRef.current = true;
initialBundleRef.current = null;
if (
!publicationsNeedDownloadFlags(
cachedBundle.publications,
isAuthenticated,
)
) {
return;
}
}
const ctrl = new AbortController();
loadBundle(ctrl.signal);
return () => ctrl.abort();
}, [orcid, loadBundle, isAuthenticated]);
useEffect(() => {
return () => {
if (syncCooldownTimerRef.current) {
clearTimeout(syncCooldownTimerRef.current);
}
if (exportCooldownTimerRef.current) {
clearTimeout(exportCooldownTimerRef.current);
}
};
}, []);
const syncDisabled = syncStatus !== "idle" || syncCooldownActive;
const exportDisabled = Boolean(exportingFormat) || exportCooldownActive;
function startSyncCooldown() {
syncCooldownUntilRef.current = Date.now() + SYNC_COOLDOWN_MS;
setSyncCooldownActive(true);
if (syncCooldownTimerRef.current) {
clearTimeout(syncCooldownTimerRef.current);
}
syncCooldownTimerRef.current = setTimeout(() => {
setSyncCooldownActive(false);
syncCooldownTimerRef.current = null;
}, SYNC_COOLDOWN_MS);
}
function startExportCooldown() {
exportCooldownUntilRef.current = Date.now() + EXPORT_COOLDOWN_MS;
setExportCooldownActive(true);
if (exportCooldownTimerRef.current) {
clearTimeout(exportCooldownTimerRef.current);
}
exportCooldownTimerRef.current = setTimeout(() => {
setExportCooldownActive(false);
exportCooldownTimerRef.current = null;
}, EXPORT_COOLDOWN_MS);
}
if (!isValidOrcid(orcid)) {
return <Navigate to="/" replace />;
}
async function handleSync() {
if (
syncInFlightRef.current ||
syncStatus !== "idle" ||
Date.now() < syncCooldownUntilRef.current
) {
return;
}
syncInFlightRef.current = true;
setSyncStatus("loading");
try {
const bundle = await syncResearcher(orcid);
setResearcher(bundle.researcher);
setPublications(bundle.publications);
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const alive = new Set(bundle.publications.map((p) => p.id));
const next = new Set();
for (const id of prev) if (alive.has(id)) next.add(id);
return next.size === prev.size ? prev : next;
});
setSyncStatus("success");
const { newRecords, updatedRecords, totalRecords } = bundle;
const hasChanges = newRecords > 0 || updatedRecords > 0;
toast.success("Sincronización completada", {
id: SYNC_TOAST_ID,
description: hasChanges
? `${newRecords} nuevas · ${updatedRecords} actualizadas (${totalRecords} total).`
: "Sin cambios desde la última sincronización.",
});
setTimeout(() => setSyncStatus("idle"), SUCCESS_FLASH_MS);
} catch (err) {
setSyncStatus("idle");
toast.error("Error al sincronizar con ORCID", {
id: SYNC_TOAST_ID,
description: err?.message ?? "Inténtalo de nuevo más tarde.",
});
} finally {
syncInFlightRef.current = false;
startSyncCooldown();
}
}
async function handleExport(format, profile = DEFAULT_EXPORT_DESTINATION) {
if (
exportInFlightRef.current ||
exportingFormat ||
Date.now() < exportCooldownUntilRef.current
) {
return;
}
exportInFlightRef.current = true;
setExportingFormat(format);
try {
let ids;
if (selectedIds.size > 0) {
// Manual selection takes priority
ids = Array.from(selectedIds);
} else if (isAuthenticated) {
// Authenticated → only download publications not yet downloaded by me
ids = newPublicationIds;
if (ids.length === 0) {
toast.info("No hay publicaciones nuevas", {
id: EXPORT_TOAST_ID,
description: "Ya has descargado todas las publicaciones de este investigador.",
});
return;
}
} else {
// Anonymous → download everything
ids = undefined;
}
const { blob } = await downloadExport(orcid, format, {
publicationIds: ids,
profile: format === "xml" ? profile : undefined,
});
if (blob) {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
const extension = format === "xml" ? "xml" : format;
anchor.download =
format === "xml"
? swordXmlFilename(orcid, profile)
: `sword-${orcid}.${extension}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
let scope;
if (selectedIds.size > 0) {
scope = `${selectedIds.size} publicación${selectedIds.size === 1 ? "" : "es"} seleccionada${selectedIds.size === 1 ? "" : "s"}`;
} else if (isAuthenticated) {
scope = `${newPublicationIds.length} publicación${newPublicationIds.length === 1 ? "" : "es"} nueva${newPublicationIds.length === 1 ? "" : "s"}`;
} else {
scope = "todo el investigador";
}
if (isAuthenticated && ids?.length) {
setPublications((prev) => markPublicationsAsDownloaded(prev, ids));
}
toast.success(`Exportación ${format.toUpperCase()} completada`, {
id: EXPORT_TOAST_ID,
description: scope,
});
} catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, {
id: EXPORT_TOAST_ID,
description: err?.message ?? "No se pudo generar el fichero.",
});
} finally {
setExportingFormat(null);
exportInFlightRef.current = false;
startExportCooldown();
}
}
return (
<div className="flex min-h-screen flex-col bg-surface-tertiary">
<AppHeader variant="dashboard" />
<main className="flex-1">
<div className="mx-auto w-full max-w-7xl px-4 py-7">
<Link
to="/"
className="mb-5 inline-flex items-center gap-1.5 text-sm text-ink-tertiary transition-colors hover:text-ink-primary"
>
<ArrowLeftIcon size={14} />
Volver al inicio
</Link>
{researcher ? (
<ResearcherCard
researcher={researcher}
actions={
<>
<SyncButton
onClick={handleSync}
status={syncStatus}
disabled={syncDisabled}
className="w-full sm:w-auto"
/>
<ExportDropdown
onExport={handleExport}
exportingFormat={exportingFormat}
disabled={exportDisabled}
selectedCount={selectedIds.size}
isAuthenticated={isAuthenticated}
newPublicationsCount={newPublicationIds.length}
exportDestination={exportDestination}
onExportDestinationChange={setExportDestination}
/>
</>
}
/>
) : (
<ResearcherSkeleton />
)}
<StatsRow publications={publications} />
<PublicationsTable
publications={publications}
loading={pubsLoading}
error={pubsError}
onRetry={() => loadBundle()}
selectedIds={selectedIds}
onSelectedIdsChange={setSelectedIds}
isAuthenticated={isAuthenticated}
/>
</div>
</main>
<Footer />
</div>
);
}
function ResearcherSkeleton() {
return (
<div className="mb-5 h-[120px] animate-pulse rounded-2xl border border-surface-border/60 bg-surface-primary" />
);
}
export default DashboardPage;