improve discover: seeded shuffle, reusable swipe gesture hook, optimistic swipes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 21:30:50 +01:00
parent ee8b9aa77f
commit b50fde1af5
7 changed files with 150 additions and 67 deletions

View File

@@ -1,51 +1,32 @@
import type { Game } from "@/shared/db/schema"
import { useCallback, useRef, useState } from "react"
import { useSwipeGesture } from "@/shared/hooks/use-swipe-gesture"
import { useEffect } from "react"
import { GameDiscoverCard } from "./game-discover-card"
function getSteamHeaderImage(sourceId: string): string {
return `https://cdn.akamai.steamstatic.com/steam/apps/${sourceId}/header.jpg`
}
interface CardStackProps {
games: Game[]
onSwipeLeft: () => void
onSwipeRight: () => void
}
const SWIPE_THRESHOLD = 100
export function CardStack({ games, onSwipeLeft, onSwipeRight }: CardStackProps) {
const [offsetX, setOffsetX] = useState(0)
const [isDragging, setIsDragging] = useState(false)
const [isExiting, setIsExiting] = useState(false)
const startXRef = useRef(0)
const { offsetX, isDragging, handlers } = useSwipeGesture({
threshold: 80,
onSwipeLeft,
onSwipeRight,
})
const handlePointerDown = useCallback((e: React.PointerEvent) => {
startXRef.current = e.clientX
setIsDragging(true)
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
}, [])
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isDragging) return
setOffsetX(e.clientX - startXRef.current)
},
[isDragging],
)
const handlePointerUp = useCallback(() => {
setIsDragging(false)
if (Math.abs(offsetX) > SWIPE_THRESHOLD) {
const direction = offsetX > 0 ? "right" : "left"
setIsExiting(true)
setOffsetX(direction === "right" ? 400 : -400)
setTimeout(() => {
if (direction === "right") onSwipeRight()
else onSwipeLeft()
setOffsetX(0)
setIsExiting(false)
}, 200)
} else {
setOffsetX(0)
}
}, [offsetX, onSwipeLeft, onSwipeRight])
// Preload 4th card's image so it's cached before entering the visible stack
const preloadGame = games[3]
useEffect(() => {
if (preloadGame?.source !== "steam") return
const img = new Image()
img.src = getSteamHeaderImage(preloadGame.source_id)
}, [preloadGame?.source, preloadGame?.source_id])
const visibleCards = games.slice(0, 3)
@@ -66,11 +47,9 @@ export function CardStack({ games, onSwipeLeft, onSwipeRight }: CardStackProps)
: `scale(${scale}) translateY(${translateY}px)`,
transition: isDragging && isTop ? "none" : "transform 0.2s ease",
zIndex: 3 - i,
opacity: isExiting && isTop ? 1 - Math.abs(offsetX) / 500 : 1,
touchAction: isTop ? "none" : undefined,
}}
onPointerDown={isTop ? handlePointerDown : undefined}
onPointerMove={isTop ? handlePointerMove : undefined}
onPointerUp={isTop ? handlePointerUp : undefined}
{...(isTop ? handlers : {})}
>
<GameDiscoverCard game={game} />
</div>

View File

@@ -13,7 +13,7 @@ export function DiscoverDone({ seenCount, onReset }: DiscoverDoneProps) {
<p className="text-muted-foreground">
{t("discover.done.message")} ({seenCount} reviewed)
</p>
<Button onClick={onReset} variant="secondary">
<Button variant="outline" onClick={onReset}>
{t("discover.reset")}
</Button>
</div>

View File

@@ -15,13 +15,11 @@ export function GameDiscoverCard({ game }: GameDiscoverCardProps) {
return (
<div className="overflow-hidden rounded-xl border bg-card shadow-lg">
{imageUrl && (
<img src={imageUrl} alt={game.title} className="h-44 w-full object-cover" loading="lazy" />
)}
{imageUrl && <img src={imageUrl} alt={game.title} className="h-44 w-full object-cover" />}
<div className="p-4">
<div className="flex items-center gap-2">
<h3 className="truncate text-lg font-bold">{game.title}</h3>
<Badge variant="outline" className="shrink-0">
<Badge variant="secondary" className="shrink-0">
{game.source}
</Badge>
</div>

View File

@@ -13,7 +13,7 @@ export function SwipeButtons({ onSkip, onLike, disabled }: SwipeButtonsProps) {
<Button
variant="outline"
size="icon"
className="h-14 w-14 rounded-full border-2 border-destructive text-destructive"
className="size-14 rounded-full border-2 border-red-500 text-red-500"
onClick={onSkip}
disabled={disabled}
>
@@ -22,7 +22,7 @@ export function SwipeButtons({ onSkip, onLike, disabled }: SwipeButtonsProps) {
<Button
variant="outline"
size="icon"
className="h-14 w-14 rounded-full border-2 border-green-500 text-green-500"
className="size-14 rounded-full border-2 border-green-500 text-green-500"
onClick={onLike}
disabled={disabled}
>

View File

@@ -1,24 +1,33 @@
import { useGames, usePlaylist, usePlaylistMutations } from "@/shared/db/hooks"
import type { Game } from "@/shared/db/schema"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useDiscoverStore } from "../store"
import { seededShuffle, useDiscoverStore } from "../store"
export function useDiscover() {
const { games: allGames, reload: reloadGames } = useGames()
const { games: allGames } = useGames()
const { games: wantToPlayGames, reload: reloadWtp } = usePlaylist("want-to-play")
const { games: notIntGames, reload: reloadNi } = usePlaylist("not-interesting")
const { addGame } = usePlaylistMutations()
const { currentIndex, setCurrentIndex, reset } = useDiscoverStore()
const { currentIndex, setCurrentIndex, seed, reset } = useDiscoverStore()
const [ready, setReady] = useState(false)
const [localSeenIds, setLocalSeenIds] = useState<Set<string>>(new Set())
const seenIds = useMemo(() => {
const ids = new Set<string>()
for (const g of wantToPlayGames) ids.add(g.id)
for (const g of notIntGames) ids.add(g.id)
for (const id of localSeenIds) ids.add(id)
return ids
}, [wantToPlayGames, notIntGames])
}, [wantToPlayGames, notIntGames, localSeenIds])
const unseenGames = useMemo(() => allGames.filter((g) => !seenIds.has(g.id)), [allGames, seenIds])
const unseenGames = useMemo(
() =>
seededShuffle(
allGames.filter((g) => !seenIds.has(g.id)),
seed,
),
[allGames, seenIds, seed],
)
useEffect(() => {
if (allGames.length > 0) setReady(true)
@@ -29,26 +38,24 @@ export function useDiscover() {
const progress =
allGames.length > 0 ? ((allGames.length - unseenGames.length) / allGames.length) * 100 : 0
const swipeRight = useCallback(async () => {
const swipeRight = useCallback(() => {
if (!currentGame) return
await addGame("want-to-play", currentGame.id)
reloadWtp()
reloadGames()
}, [currentGame, addGame, reloadWtp, reloadGames])
setLocalSeenIds((prev) => new Set(prev).add(currentGame.id))
addGame("want-to-play", currentGame.id)
}, [currentGame, addGame])
const swipeLeft = useCallback(async () => {
const swipeLeft = useCallback(() => {
if (!currentGame) return
await addGame("not-interesting", currentGame.id)
reloadNi()
reloadGames()
}, [currentGame, addGame, reloadNi, reloadGames])
setLocalSeenIds((prev) => new Set(prev).add(currentGame.id))
addGame("not-interesting", currentGame.id)
}, [currentGame, addGame])
const handleReset = useCallback(async () => {
const handleReset = useCallback(() => {
reset()
reloadGames()
setLocalSeenIds(new Set())
reloadWtp()
reloadNi()
}, [reset, reloadGames, reloadWtp, reloadNi])
}, [reset, reloadWtp, reloadNi])
return {
currentGame,

View File

@@ -2,6 +2,7 @@ import { create } from "zustand"
interface DiscoverState {
currentIndex: number
seed: number
animatingDirection: "left" | "right" | null
setCurrentIndex: (index: number) => void
setAnimatingDirection: (dir: "left" | "right" | null) => void
@@ -10,8 +11,31 @@ interface DiscoverState {
export const useDiscoverStore = create<DiscoverState>((set) => ({
currentIndex: 0,
seed: Math.random(),
animatingDirection: null,
setCurrentIndex: (currentIndex) => set({ currentIndex }),
setAnimatingDirection: (animatingDirection) => set({ animatingDirection }),
reset: () => set({ currentIndex: 0, animatingDirection: null }),
reset: () => set({ currentIndex: 0, seed: Math.random(), animatingDirection: null }),
}))
/** Mulberry32 — fast seeded 32-bit PRNG */
export function mulberry32(seed: number): () => number {
let s = seed | 0 || 1
return () => {
s = (s + 0x6d2b79f5) | 0
let t = Math.imul(s ^ (s >>> 15), 1 | s)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/** Fisher-Yates shuffle using a seeded PRNG */
export function seededShuffle<T>(arr: readonly T[], seed: number): T[] {
const result = arr.slice()
const rng = mulberry32(Math.floor(seed * 2147483647))
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1))
;[result[i], result[j]] = [result[j], result[i]]
}
return result
}

View File

@@ -0,0 +1,75 @@
import { useCallback, useRef, useState } from "react"
interface SwipeGestureOptions {
/** Minimum px to travel before triggering a swipe (default: 80) */
threshold?: number
/** Called when swiped past threshold to the left */
onSwipeLeft?: () => void
/** Called when swiped past threshold to the right */
onSwipeRight?: () => void
}
interface SwipeGestureResult {
offsetX: number
isDragging: boolean
handlers: {
onPointerDown: (e: React.PointerEvent) => void
onPointerMove: (e: React.PointerEvent) => void
onPointerUp: () => void
}
}
export function useSwipeGesture({
threshold = 80,
onSwipeLeft,
onSwipeRight,
}: SwipeGestureOptions): SwipeGestureResult {
const [offsetX, setOffsetX] = useState(0)
const [isDragging, setIsDragging] = useState(false)
const startXRef = useRef(0)
const offsetRef = useRef(0)
const onSwipeLeftRef = useRef(onSwipeLeft)
const onSwipeRightRef = useRef(onSwipeRight)
onSwipeLeftRef.current = onSwipeLeft
onSwipeRightRef.current = onSwipeRight
const handlePointerDown = useCallback((e: React.PointerEvent) => {
startXRef.current = e.clientX
offsetRef.current = 0
setIsDragging(true)
setOffsetX(0)
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}, [])
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isDragging) return
const dx = e.clientX - startXRef.current
offsetRef.current = dx
setOffsetX(dx)
},
[isDragging],
)
const handlePointerUp = useCallback(() => {
if (!isDragging) return
setIsDragging(false)
const dx = offsetRef.current
if (Math.abs(dx) > threshold) {
if (dx > 0) onSwipeRightRef.current?.()
else onSwipeLeftRef.current?.()
}
setOffsetX(0)
offsetRef.current = 0
}, [isDragging, threshold])
return {
offsetX,
isDragging,
handlers: {
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
},
}
}