hensei-web/src/routes/[username]/+page.svelte
devin-ai-integration[bot] f9bb43f214
feat: migrate components to TanStack Query v6 (Phase 2) (#442)
# feat: migrate components to TanStack Query v6 (Phase 2)

## Summary

This PR migrates 4 components from the custom
`createInfiniteScrollResource` pattern to TanStack Query v6's
`createInfiniteQuery`:

- **JobSkillSelectionSidebar** - Job skill search with infinite scroll
and category filtering
- **SearchContent** - Search modal for weapons/characters/summons with
element/rarity/proficiency filters
- **User Profile Page** (`[username]/+page.svelte`) - User's teams and
favorites with tab switching
- **Teams Explore Page** (`teams/explore/+page.svelte`) - Public teams
listing

All components now use:
- TanStack Query v6 infinite query pattern with thunk for reactivity
- `IsInViewport` from runed for intersection-based infinite scroll
- Debounced search (debounce the value, not the query)
- Proper loading, error, and empty states
- SSR integration with `initialData` pattern

## Updates since last revision

- **Fixed duplicate key error in SearchContent.svelte** - The API can
return the same item across multiple pages during infinite scroll (e.g.,
due to items being added/removed between page fetches). This caused
Svelte's keyed each block to throw `each_key_duplicate` errors. Fixed by
deduplicating results by `id` using a Map before rendering.

## Review & Testing Checklist for Human

This is a medium-risk change affecting core user-facing pages. Please
verify:

- [ ] **Infinite scroll works on all 4 pages** - Scroll to bottom and
verify more items load automatically
- [ ] **No duplicate items appear in search results** - After the fix,
scrolling through many pages of search results should not show
duplicates
- [ ] **SSR hydration** - Verify no flash of loading state on initial
page load (data should be pre-rendered)
- [ ] **User profile tab switching** - Test switching between "Teams"
and "Favorites" tabs; verify correct data loads
- [ ] **Search debouncing** - Type quickly in JobSkillSelectionSidebar
and SearchContent; verify queries aren't fired on every keystroke
- [ ] **Error states** - Simulate network failure and verify retry
button works

**Recommended Test Plan:**
1. Navigate to `/teams/explore` - verify teams load and infinite scroll
works
2. Navigate to a user profile page - verify teams load, switch to
favorites tab, verify favorites load
3. Open the search sidebar - search for weapons/characters/summons,
scroll through many pages, verify no duplicate key errors and no
duplicate items
4. Open job skill selection - search and filter skills, verify results

### Notes

- The Party component mutations migration (Follow-Up Prompt 5) was
deferred to a follow-up PR due to complexity
- Deprecated resource classes remain in codebase for now; removal
planned for separate PR
- Pre-existing paraglide module errors in build are unrelated to this PR
- Type assertions (`as unknown as`, `as any`) are used to handle
different query result structures between favorites and parties queries

**Link to Devin run:**
https://app.devin.ai/sessions/5aa7ea29edf34f569f95f13acee9e0d9
**Requested by:** Justin Edmund (justin@jedmund.com) / @jedmund

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Justin Edmund <justin@jedmund.com>
2025-11-29 01:11:24 -08:00

263 lines
5.9 KiB
Svelte

<script lang="ts">
import type { PageData } from './$types'
import { createInfiniteQuery } from '@tanstack/svelte-query'
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
import { userQueries } from '$lib/api/queries/user.queries'
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
import { IsInViewport } from 'runed'
import Icon from '$lib/components/Icon.svelte'
import Button from '$lib/components/ui/Button.svelte'
const { data } = $props() as { data: PageData }
const tab = $derived(data.tab || 'teams')
const isOwner = $derived(data.isOwner || false)
const avatarFile = $derived(data.user?.avatar?.picture || '')
const avatarSrc = $derived(getAvatarSrc(avatarFile))
const avatarSrcSet = $derived(getAvatarSrcSet(avatarFile))
// Note: Type assertion needed because favorites and parties queries have different
// result structures (items vs results) but we handle both in the items $derived
const partiesQuery = createInfiniteQuery(() => {
const isFavorites = tab === 'favorites' && isOwner
if (isFavorites) {
return {
...userQueries.favorites(),
initialData: data.items
? {
pages: [
{
items: data.items,
page: data.page || 1,
totalPages: data.totalPages,
total: data.total,
perPage: data.perPage || 20
}
],
pageParams: [1]
}
: undefined,
initialDataUpdatedAt: 0
}
}
return {
...userQueries.parties(data.user?.username ?? ''),
enabled: !!data.user?.username,
initialData: data.items
? {
pages: [
{
results: data.items,
page: data.page || 1,
totalPages: data.totalPages,
total: data.total,
perPage: data.perPage || 20
}
],
pageParams: [1]
}
: undefined,
initialDataUpdatedAt: 0
} as unknown as ReturnType<typeof userQueries.favorites>
})
const items = $derived(() => {
if (!partiesQuery.data?.pages) return data.items || []
const isFavorites = tab === 'favorites' && isOwner
if (isFavorites) {
return partiesQuery.data.pages.flatMap((page) => (page as any).items ?? [])
}
return partiesQuery.data.pages.flatMap((page) => (page as any).results ?? [])
})
const isEmpty = $derived(!partiesQuery.isLoading && items().length === 0)
const showSentinel = $derived(partiesQuery.hasNextPage && !partiesQuery.isFetchingNextPage)
let sentinelEl = $state<HTMLElement>()
const inViewport = new IsInViewport(() => sentinelEl, {
rootMargin: '300px'
})
$effect(() => {
if (
inViewport.current &&
partiesQuery.hasNextPage &&
!partiesQuery.isFetchingNextPage &&
!partiesQuery.isLoading
) {
partiesQuery.fetchNextPage()
}
})
</script>
<section class="profile">
<header class="header">
{#if data.user?.avatar?.picture}
<img
class="avatar"
alt={`Avatar of ${data.user.username}`}
src={avatarSrc}
srcset={avatarSrcSet}
width="64"
height="64"
/>
{:else}
<div class="avatar" aria-hidden="true"></div>
{/if}
<div>
<h1>{data.user.username}</h1>
<nav class="tabs" aria-label="Profile sections">
<a class:active={tab === 'teams'} href="?tab=teams" data-sveltekit-preload-data="hover"
>Teams</a
>
{#if isOwner}
<a
class:active={tab === 'favorites'}
href="?tab=favorites"
data-sveltekit-preload-data="hover">Favorites</a
>
{/if}
</nav>
</div>
</header>
{#if partiesQuery.isLoading}
<div class="loading">
<Icon name="loader-2" size={32} />
<p>Loading {tab}...</p>
</div>
{:else if partiesQuery.isError}
<div class="error">
<Icon name="alert-circle" size={32} />
<p>Failed to load {tab}: {partiesQuery.error?.message || 'Unknown error'}</p>
<Button size="small" onclick={() => partiesQuery.refetch()}>Retry</Button>
</div>
{:else if isEmpty}
<div class="empty">
<p>{tab === 'favorites' ? 'No favorite teams yet' : 'No teams found'}</p>
</div>
{:else}
<div class="profile-grid">
<ExploreGrid items={items()} />
{#if showSentinel}
<div class="load-more-sentinel" bind:this={sentinelEl}></div>
{/if}
{#if partiesQuery.isFetchingNextPage}
<div class="loading-more">
<Icon name="loader-2" size={20} />
<span>Loading more...</span>
</div>
{/if}
{#if !partiesQuery.hasNextPage && items().length > 0}
<div class="end">
<p>You've seen all {tab === 'favorites' ? 'favorites' : 'teams'}!</p>
</div>
{/if}
</div>
{/if}
</section>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
.profile {
padding: $unit-2x 0;
}
.header {
display: flex;
align-items: center;
gap: $unit-2x;
margin-bottom: $unit-2x;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: $grey-80;
border: 1px solid $grey-75;
object-fit: cover;
}
.sub {
color: $grey-55;
margin: 0;
}
.tabs {
display: flex;
gap: $unit-2x;
margin-top: $unit-half;
}
.tabs a {
text-decoration: none;
color: inherit;
padding-bottom: 2px;
border-bottom: 2px solid transparent;
}
.tabs a.active {
border-color: #3366ff;
color: #3366ff;
}
.empty,
.end,
.error {
text-align: center;
padding: $unit-4x;
color: var(--text-secondary);
p {
margin: 0;
}
}
.error {
color: var(--text-error, #dc2626);
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-4x;
color: var(--text-secondary);
:global(svg) {
animation: spin 1s linear infinite;
}
p {
margin: 0;
}
}
.load-more-sentinel {
height: 1px;
margin-top: $unit;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-2x;
color: var(--text-secondary);
:global(svg) {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>