diff --git a/src/components/explore/ExploreResults.tsx b/src/components/explore/ExploreResults.tsx
index 9081dd6..680cdd9 100644
--- a/src/components/explore/ExploreResults.tsx
+++ b/src/components/explore/ExploreResults.tsx
@@ -21,8 +21,6 @@ type ExploreResultsProps = Pick<
| 'isResultsTransitioning'
| 'hasCompletedInitialLoad'
| 'quotes'
- | 'filters'
- | 'totalPages'
| 'handleGenerateMore'
| 'handleClearFilters'
>
@@ -33,8 +31,6 @@ export function ExploreResults({
isResultsTransitioning,
hasCompletedInitialLoad,
quotes,
- filters,
- totalPages,
handleGenerateMore,
handleClearFilters,
}: ExploreResultsProps) {
@@ -139,12 +135,6 @@ export function ExploreResults({
Otra frase
-
- {totalPages > 1 ? (
-
- Selección {filters.page} de {totalPages}
-
- ) : null}
diff --git a/src/hooks/useExploreQuotes.ts b/src/hooks/useExploreQuotes.ts
index 000bb44..2379b31 100644
--- a/src/hooks/useExploreQuotes.ts
+++ b/src/hooks/useExploreQuotes.ts
@@ -2,12 +2,12 @@ import type { Dispatch, FormEvent, SetStateAction } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { getQuoteTypes, getSituations } from '../services/catalogService'
-import { getQuotes } from '../services/quotesService'
+import { getQuotes, getRandomQuotesPool } from '../services/quotesService'
import type { QuoteType, Situation } from '../types/catalog'
import type { Quote } from '../types/quote'
const STORAGE_KEY = 'quotematic:explore-filters'
-const POOL_SIZE = 10
+const POOL_SIZE = 20
const TRANSITION_DELAY_MS = 180
export type ExploreFilters = {
@@ -69,12 +69,16 @@ function shuffleQuotes(quotes: Quote[]): Quote[] {
return copy
}
-// Picks the best next quote from a pool, avoiding seen ids/authors.
-// Priority: (1) unseen id + unseen/unknown author → (2) unseen id → (3) null (all seen)
+// Picks the best next quote from a pool, applying the following cascade:
+// P1: unseen id + unseen author + author != lastAuthorKey
+// P2: unseen id + author != lastAuthorKey
+// P3: unseen id (author may repeat the last one)
+// null: every id in the pool has been seen → caller should reset / refetch
function pickQuote(
candidates: Quote[],
seenIds: Set
,
seenAuthors: Set,
+ lastAuthorKey: string | null,
currentId?: string,
): Quote | null {
if (candidates.length === 0) return null
@@ -87,18 +91,29 @@ function pickQuote(
const pool = available.length > 0 ? available : candidates
- // P1: unseen id + unseen author
- const p1 = pool.filter((q) => {
- if (seenIds.has(q._id)) return false
- return !seenAuthors.has(getAuthorKey(q))
- })
+ const isDifferentFromLastAuthor = (q: Quote) =>
+ lastAuthorKey === null || getAuthorKey(q) !== lastAuthorKey
+
+ // P1: unseen id + unseen author + distinct from last author
+ const p1 = pool.filter(
+ (q) =>
+ !seenIds.has(q._id) &&
+ !seenAuthors.has(getAuthorKey(q)) &&
+ isDifferentFromLastAuthor(q),
+ )
if (p1.length) return p1[Math.floor(Math.random() * p1.length)]
- // P2: unseen id (author may repeat)
- const p2 = pool.filter((q) => !seenIds.has(q._id))
+ // P2: unseen id + distinct from last author
+ const p2 = pool.filter(
+ (q) => !seenIds.has(q._id) && isDifferentFromLastAuthor(q),
+ )
if (p2.length) return p2[Math.floor(Math.random() * p2.length)]
- // P3: all ids seen → signal exhaustion
+ // P3: unseen id (the same author as last is accepted as last resort)
+ const p3 = pool.filter((q) => !seenIds.has(q._id))
+ if (p3.length) return p3[Math.floor(Math.random() * p3.length)]
+
+ // All ids seen → caller handles P4 (reset seenIds and/or refetch)
return null
}
@@ -152,6 +167,12 @@ export function useExploreQuotes(): UseExploreQuotesResult {
const transitionTimeoutRef = useRef(null)
const seenQuoteIdsRef = useRef>(new Set())
const seenAuthorKeysRef = useRef>(new Set())
+ // Tracks the author of the quote currently being shown so we can avoid
+ // showing two quotes from the same author in a row.
+ const lastAuthorKeyRef = useRef(null)
+ // Guards against infinite refetch loops when no different-author candidate
+ // can be found locally: at most one fallback refetch per user click.
+ const hasRefetchedForDiversityRef = useRef(false)
const hasActiveFilters = useMemo(
() => Boolean(filters.search || filters.situation || filters.quoteType),
@@ -229,13 +250,38 @@ export function useExploreQuotes(): UseExploreQuotesResult {
}
}
- getQuotes({
- search: filters.search || undefined,
- situation: filters.situation || undefined,
- quoteType: filters.quoteType || undefined,
- page: filters.page,
- limit: POOL_SIZE,
- })
+ const trimmedSearch = filters.search.trim()
+ const isSearchMode = trimmedSearch.length > 0
+
+ // Random pool uses backend-side randomness; search keeps the deterministic
+ // paginated endpoint because /api/quotes/random does not accept `search`.
+ const fetchPool: Promise<{
+ data: Quote[]
+ total: number
+ totalPages: number
+ }> = isSearchMode
+ ? getQuotes({
+ search: trimmedSearch,
+ situation: filters.situation || undefined,
+ quoteType: filters.quoteType || undefined,
+ page: filters.page,
+ limit: POOL_SIZE,
+ }).then((response) => ({
+ data: response.data,
+ total: response.meta.total,
+ totalPages: response.meta.totalPages || 1,
+ }))
+ : getRandomQuotesPool({
+ count: POOL_SIZE,
+ situation: filters.situation || undefined,
+ quoteType: filters.quoteType || undefined,
+ }).then((response) => ({
+ data: response.data,
+ total: response.meta.returned,
+ totalPages: 1,
+ }))
+
+ fetchPool
.then((response) => {
if (!isMounted) return
@@ -245,11 +291,25 @@ export function useExploreQuotes(): UseExploreQuotesResult {
newPool,
seenQuoteIdsRef.current,
seenAuthorKeysRef.current,
+ lastAuthorKeyRef.current,
)
+ const chosen = selected ?? newPool[0] ?? null
setPool(newPool)
- setDisplayed(selected ?? newPool[0] ?? null)
- setTotalPages(response.meta.totalPages || 1)
- setTotalQuotes(response.meta.total)
+ setDisplayed(chosen)
+ if (chosen) {
+ const newKey = getAuthorKey(chosen)
+ // If the freshly chosen quote moves us off the previous author,
+ // forget that we already refetched for diversity in this cycle.
+ if (
+ lastAuthorKeyRef.current !== null &&
+ lastAuthorKeyRef.current !== newKey
+ ) {
+ hasRefetchedForDiversityRef.current = false
+ }
+ lastAuthorKeyRef.current = newKey
+ }
+ setTotalPages(response.totalPages)
+ setTotalQuotes(response.total)
setQuotesError(null)
hasVisibleQuotesRef.current = newPool.length > 0
})
@@ -281,6 +341,8 @@ export function useExploreQuotes(): UseExploreQuotesResult {
function resetHistory() {
seenQuoteIdsRef.current = new Set()
seenAuthorKeysRef.current = new Set()
+ lastAuthorKeyRef.current = null
+ hasRefetchedForDiversityRef.current = false
}
function prepareQuoteRequest() {
@@ -334,27 +396,88 @@ export function useExploreQuotes(): UseExploreQuotesResult {
seenAuthorKeysRef.current.add(getAuthorKey(displayed))
}
+ const lastAuthor = lastAuthorKeyRef.current
+ const currentId = displayed?._id
+
+ // Rule 8: if the current local pool has no author distinct from the last
+ // shown one, ask the backend for a new pool before allowing repetition.
+ // Capped to a single defensive refetch per click (rule 9).
+ const poolHasDifferentAuthor =
+ lastAuthor === null || pool.some((q) => getAuthorKey(q) !== lastAuthor)
+
+ if (
+ !poolHasDifferentAuthor &&
+ !hasRefetchedForDiversityRef.current &&
+ pool.length > 0
+ ) {
+ hasRefetchedForDiversityRef.current = true
+ prepareQuoteRequest()
+
+ const isSearchMode = filters.search.trim().length > 0
+ if (isSearchMode && totalPages > 1) {
+ setFilters((f) => ({
+ ...f,
+ page: Math.floor(Math.random() * totalPages) + 1,
+ }))
+ } else {
+ setRefreshIndex((v) => v + 1)
+ }
+ return
+ }
+
+ // Normal cascade P1 → P2 → P3
const next = pickQuote(
pool,
seenQuoteIdsRef.current,
seenAuthorKeysRef.current,
- displayed?._id,
+ lastAuthor,
+ currentId,
)
if (next) {
+ const newKey = getAuthorKey(next)
+ if (lastAuthor !== null && newKey !== lastAuthor) {
+ hasRefetchedForDiversityRef.current = false
+ }
+ lastAuthorKeyRef.current = newKey
navigateInPool(next)
return
}
- // Pool exhausted — reset only quote ids (keep author history across pools)
+ // P4: every id in the local pool is already seen → reset seenIds (keep
+ // seenAuthors / lastAuthor) and retry with the diversity rules still on.
seenQuoteIdsRef.current = new Set()
+ const retried = pickQuote(
+ pool,
+ seenQuoteIdsRef.current,
+ seenAuthorKeysRef.current,
+ lastAuthor,
+ currentId,
+ )
+ if (retried) {
+ const newKey = getAuthorKey(retried)
+ if (lastAuthor !== null && newKey !== lastAuthor) {
+ hasRefetchedForDiversityRef.current = false
+ }
+ lastAuthorKeyRef.current = newKey
+ navigateInPool(retried)
+ return
+ }
+
+ // Local pool truly cannot help → ask backend for a fresh pool.
prepareQuoteRequest()
- setFilters((f) => ({
- ...f,
- // Random page instead of sequential to avoid predictable page grouping
- page: totalPages <= 1 ? 1 : Math.floor(Math.random() * totalPages) + 1,
- }))
- setRefreshIndex((v) => v + 1)
+
+ const isSearchMode = filters.search.trim().length > 0
+ if (isSearchMode && totalPages > 1) {
+ // Search keeps deterministic pagination, so jump to a random page.
+ setFilters((f) => ({
+ ...f,
+ page: Math.floor(Math.random() * totalPages) + 1,
+ }))
+ } else {
+ // Random mode just refetches; backend will return a new random pool.
+ setRefreshIndex((v) => v + 1)
+ }
}
function handleClearFilters() {
diff --git a/src/pages/ExplorePage.tsx b/src/pages/ExplorePage.tsx
index c67506d..dc0ba13 100644
--- a/src/pages/ExplorePage.tsx
+++ b/src/pages/ExplorePage.tsx
@@ -42,8 +42,6 @@ export function ExplorePage() {
isResultsTransitioning={explore.isResultsTransitioning}
hasCompletedInitialLoad={explore.hasCompletedInitialLoad}
quotes={explore.quotes}
- filters={explore.filters}
- totalPages={explore.totalPages}
handleGenerateMore={explore.handleGenerateMore}
handleClearFilters={explore.handleClearFilters}
/>
diff --git a/src/services/quotesService.ts b/src/services/quotesService.ts
index 7d8ef7f..a0ed34e 100644
--- a/src/services/quotesService.ts
+++ b/src/services/quotesService.ts
@@ -1,6 +1,10 @@
import { apiClient } from './apiClient'
-import type { ApiPaginatedResponse, ApiSuccessResponse } from '../types/api'
-import type { Quote, QuoteFilters } from '../types/quote'
+import type {
+ ApiPaginatedResponse,
+ ApiRandomPoolResponse,
+ ApiSuccessResponse,
+} from '../types/api'
+import type { Quote, QuoteFilters, RandomQuoteFilters } from '../types/quote'
export function getRandomQuote(filters?: QuoteFilters) {
return apiClient>('/api/quotes/random', {
@@ -8,6 +12,12 @@ export function getRandomQuote(filters?: QuoteFilters) {
})
}
+export function getRandomQuotesPool(filters?: RandomQuoteFilters) {
+ return apiClient>('/api/quotes/random', {
+ query: filters,
+ })
+}
+
export function getQuotes(filters?: QuoteFilters) {
return apiClient>('/api/quotes', {
query: filters,
diff --git a/src/types/api.ts b/src/types/api.ts
index 23533f9..5a826b9 100644
--- a/src/types/api.ts
+++ b/src/types/api.ts
@@ -14,6 +14,15 @@ export type ApiPaginatedResponse = {
}
}
+export type ApiRandomPoolResponse = {
+ success: true
+ data: T[]
+ meta: {
+ count: number
+ returned: number
+ }
+}
+
export type ApiErrorResponse = {
success?: false
message?: string
diff --git a/src/types/quote.ts b/src/types/quote.ts
index 4ddd036..50737b9 100644
--- a/src/types/quote.ts
+++ b/src/types/quote.ts
@@ -32,4 +32,11 @@ export type QuoteFilters = {
search?: string
page?: number
limit?: number
+}
+
+export type RandomQuoteFilters = {
+ count?: number
+ situation?: string
+ quoteType?: string
+ contentRating?: ContentRating
}
\ No newline at end of file