feat: add TanStack Query v6 integration with SSR support (#441)

## Summary

This PR establishes the foundation for migrating from custom Svelte 5
resource classes to TanStack Query v6 for server state management. It
adds:

**Query Options Factories** (in `src/lib/api/queries/`):
- `party.queries.ts` - Party fetching with infinite scroll support
- `job.queries.ts` - Job and skill queries with pagination
- `user.queries.ts` - User profile, parties, and favorites queries

**Mutation Configurations** (in `src/lib/api/mutations/`):
- `party.mutations.ts` - Party CRUD with cache invalidation
- `grid.mutations.ts` - Weapon/character/summon mutations with
optimistic updates
- `job.mutations.ts` - Job and skill update mutations

**Deprecation Notices**:
- Added `@deprecated` JSDoc to `search.resource.svelte.ts` and
`party.resource.svelte.ts` with migration examples

**SSR Integration** (Phase 4):
- Created `+layout.ts` to initialize QueryClient for SSR support
- Updated `+layout.svelte` to receive QueryClient from load function
- Added SSR utilities in `src/lib/query/ssr.ts`:
  - `withInitialData()` - for pages using +page.server.ts
- `prefetchQuery()` / `prefetchInfiniteQuery()` - for pages using
+page.ts
  - `setQueryData()` - for direct cache population
- Added documentation in `src/lib/query/README.md`

**Component Wiring Examples** (Phase 5):
- `JobSelectionSidebar.svelte` - Migrated from `createJobResource()` to
`createQuery(() => jobQueries.list())`. Demonstrates client-side query
pattern with automatic loading/error states.
- `teams/[id]/+page.svelte` - Added `withInitialData()` pattern for SSR
integration. Server-fetched party data is used as initial cache value
with background refetching support.

**Migration Guide**:
- Added `src/lib/query/MIGRATION.md` with follow-up prompts for
remaining component migrations (JobSkillSelectionSidebar, search modal,
user profile, teams explore, Party mutations, resource class removal)

## Updates Since Last Revision

Fixed TypeScript type errors in the TanStack Query integration:
- `party.queries.ts`: Made `total` and `perPage` optional in
`PartyPageResult` interface to match adapter return type
- `ssr.ts`: Fixed `withInitialData` to properly handle null values using
`NonNullable<TData>` return type
- `job.mutations.ts`: Fixed slot indexing by casting through `unknown`
to `keyof typeof updatedSkills`

Type checks now pass for all files modified in this PR (16 remaining
errors are pre-existing project issues unrelated to this PR - paraglide
modules not generated, hooks.ts implicit anys).

## Review & Testing Checklist for Human

- [ ] **Verify app loads correctly**: The `+layout.ts` and
`+layout.svelte` changes are critical path - confirm the app still
renders
- [ ] **Test JobSelectionSidebar**: Open job selection sidebar and
verify jobs load correctly, search/filter works, and retry button works
on error
- [ ] **Test teams/[id] page**: Navigate to a party detail page and
verify it renders without loading flash (SSR data should be immediate)
- [ ] **Review type casts**: Check `job.mutations.ts:135` - the `as
unknown as keyof typeof` cast for slot indexing is a workaround for
jobSkills having string literal keys ('0', '1', '2', '3') while slot is
a number
- [ ] **Verify withInitialData behavior**: The `NonNullable<TData>`
return type change in `ssr.ts` should work correctly with `data.party`
which can be `Party | null`

**Recommended test plan**: 
1. Run `pnpm install` to ensure dependencies are up to date
2. Start dev server and verify the app loads without errors
3. Navigate to a party detail page (`/teams/[shortcode]`) - should
render immediately without loading state
4. Open job selection sidebar (click job icon on a party you can edit) -
verify jobs load and filtering works
5. Test error handling by temporarily breaking network - verify retry
button appears

### Notes


- Pre-existing project issues remain (paraglide modules not generated,
hooks.ts implicit anys) - these are unrelated to this PR
- Local testing could not run due to missing node_modules (vite not
found) - project setup issue
- TanStack Query devtools installation was skipped due to Storybook
version conflicts
- The existing `search.queries.ts` file was used as the pattern
reference for new query factories
- SSR approach uses hybrid pattern: existing `+page.server.ts` files
work with `withInitialData()`, while new pages can use `prefetchQuery()`
in `+page.ts`
- Migration guide includes 6 follow-up prompts for completing the
remaining component migrations

**Link to Devin run**:
https://app.devin.ai/sessions/33e97a98ae3e415aa4dc35378cad3a2b
**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>
This commit is contained in:
devin-ai-integration[bot] 2025-11-29 00:36:59 -08:00 committed by GitHub
parent 53405da7eb
commit 5764161803
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2294 additions and 40 deletions

View file

@ -4,6 +4,27 @@
* Provides reactive state management for party operations with
* automatic loading states, error handling, and optimistic updates.
*
* @deprecated This resource class is deprecated in favor of TanStack Query.
* Use `partyQueries` from `$lib/api/queries/party.queries` and
* mutation hooks from `$lib/api/mutations/party.mutations` instead.
*
* Migration example:
* ```typescript
* // Before (PartyResource)
* const party = createPartyResource()
* party.load('ABC123')
* party.update({ shortcode: 'ABC123', name: 'New Name' })
*
* // After (TanStack Query)
* import { createQuery } from '@tanstack/svelte-query'
* import { partyQueries } from '$lib/api/queries/party.queries'
* import { useUpdateParty } from '$lib/api/mutations/party.mutations'
*
* const party = createQuery(() => partyQueries.byShortcode('ABC123'))
* const updateParty = useUpdateParty()
* updateParty.mutate({ shortcode: 'ABC123', name: 'New Name' })
* ```
*
* @module adapters/resources/party
*/

View file

@ -4,6 +4,23 @@
* Provides reactive state management for search operations with
* automatic loading states, error handling, and debouncing.
*
* @deprecated This resource class is deprecated in favor of TanStack Query.
* Use `searchQueries` from `$lib/api/queries/search.queries` with `createInfiniteQuery` instead.
*
* Migration example:
* ```typescript
* // Before (SearchResource)
* const search = createSearchResource({ debounceMs: 300 })
* search.searchWeapons({ query })
*
* // After (TanStack Query)
* import { createInfiniteQuery } from '@tanstack/svelte-query'
* import { searchQueries } from '$lib/api/queries/search.queries'
*
* let debouncedQuery = $state('')
* const weapons = createInfiniteQuery(() => searchQueries.weapons(debouncedQuery))
* ```
*
* @module adapters/resources/search
*/
@ -227,4 +244,4 @@ export class SearchResource {
*/
export function createSearchResource(options?: SearchResourceOptions): SearchResource {
return new SearchResource(options)
}
}

View file

@ -0,0 +1,568 @@
/**
* Grid Mutation Configurations
*
* Provides mutation configurations for grid item operations (weapons, characters, summons)
* with cache invalidation and optimistic updates using TanStack Query v6.
*
* @module api/mutations/grid
*/
import { useQueryClient, createMutation } from '@tanstack/svelte-query'
import {
gridAdapter,
type CreateGridWeaponParams,
type CreateGridCharacterParams,
type CreateGridSummonParams,
type UpdateUncapParams,
type ResolveConflictParams
} from '$lib/api/adapters/grid.adapter'
import { partyKeys } from '$lib/api/queries/party.queries'
import type { Party, GridWeapon, GridCharacter, GridSummon } from '$lib/types/api/party'
// ============================================================================
// Weapon Mutations
// ============================================================================
/**
* Create grid weapon mutation
*
* Adds a weapon to a party's grid.
*
* @example
* ```svelte
* <script lang="ts">
* import { useCreateGridWeapon } from '$lib/api/mutations/grid.mutations'
*
* const createWeapon = useCreateGridWeapon()
*
* function handleAddWeapon() {
* createWeapon.mutate({
* partyId: 'party-uuid',
* weaponId: 'weapon-id',
* position: 1
* })
* }
* </script>
* ```
*/
export function useCreateGridWeapon() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (params: CreateGridWeaponParams) => gridAdapter.createWeapon(params),
onSuccess: (_data, params) => {
// Invalidate the party to refetch with new weapon
queryClient.invalidateQueries({ queryKey: partyKeys.detail(params.partyId) })
}
}))
}
/**
* Update grid weapon mutation
*
* Updates a weapon in a party's grid with optimistic updates.
*
* @example
* ```svelte
* <script lang="ts">
* import { useUpdateGridWeapon } from '$lib/api/mutations/grid.mutations'
*
* const updateWeapon = useUpdateGridWeapon()
*
* function handleUpdateWeapon(id: string, partyShortcode: string) {
* updateWeapon.mutate({
* id,
* partyShortcode,
* updates: { element: 2 }
* })
* }
* </script>
* ```
*/
export function useUpdateGridWeapon() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ id, updates }: { id: string; partyShortcode: string; updates: Partial<GridWeapon> }) =>
gridAdapter.updateWeapon(id, updates),
onMutate: async ({ id, partyShortcode, updates }) => {
await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) })
const previousParty = queryClient.getQueryData<Party>(partyKeys.detail(partyShortcode))
if (previousParty?.weapons) {
const updatedWeapons = previousParty.weapons.map((w) =>
w.id === id ? { ...w, ...updates } : w
)
queryClient.setQueryData(partyKeys.detail(partyShortcode), {
...previousParty,
weapons: updatedWeapons
})
}
return { previousParty }
},
onError: (_err, { partyShortcode }, context) => {
if (context?.previousParty) {
queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty)
}
},
onSettled: (_data, _err, { partyShortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) })
}
}))
}
/**
* Delete grid weapon mutation
*
* Removes a weapon from a party's grid.
*/
export function useDeleteGridWeapon() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (params: { id?: string; partyId: string; partyShortcode: string; position?: number }) =>
gridAdapter.deleteWeapon({ id: params.id, partyId: params.partyId, position: params.position }),
onMutate: async ({ partyShortcode, id, position }) => {
await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) })
const previousParty = queryClient.getQueryData<Party>(partyKeys.detail(partyShortcode))
if (previousParty?.weapons) {
const updatedWeapons = previousParty.weapons.filter((w) =>
id ? w.id !== id : w.position !== position
)
queryClient.setQueryData(partyKeys.detail(partyShortcode), {
...previousParty,
weapons: updatedWeapons
})
}
return { previousParty }
},
onError: (_err, { partyShortcode }, context) => {
if (context?.previousParty) {
queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty)
}
},
onSettled: (_data, _err, { partyShortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) })
}
}))
}
/**
* Update weapon uncap mutation
*
* Updates a weapon's uncap level with optimistic updates.
*/
export function useUpdateWeaponUncap() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (params: UpdateUncapParams & { partyShortcode: string }) =>
gridAdapter.updateWeaponUncap(params),
onMutate: async ({ partyShortcode, id, uncapLevel, transcendenceStep }) => {
await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) })
const previousParty = queryClient.getQueryData<Party>(partyKeys.detail(partyShortcode))
if (previousParty?.weapons) {
const updatedWeapons = previousParty.weapons.map((w) =>
w.id === id
? {
...w,
uncapLevel,
...(transcendenceStep !== undefined && { transcendenceStep })
}
: w
)
queryClient.setQueryData(partyKeys.detail(partyShortcode), {
...previousParty,
weapons: updatedWeapons
})
}
return { previousParty }
},
onError: (_err, { partyShortcode }, context) => {
if (context?.previousParty) {
queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty)
}
},
onSettled: (_data, _err, { partyShortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) })
}
}))
}
/**
* Resolve weapon conflict mutation
*
* Resolves conflicts when adding a weapon that conflicts with existing weapons.
*/
export function useResolveWeaponConflict() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (params: ResolveConflictParams & { partyShortcode: string }) =>
gridAdapter.resolveWeaponConflict(params),
onSuccess: (_data, { partyShortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) })
}
}))
}
// ============================================================================
// Character Mutations
// ============================================================================
/**
* Create grid character mutation
*
* Adds a character to a party's grid.
*/
export function useCreateGridCharacter() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (params: CreateGridCharacterParams) => gridAdapter.createCharacter(params),
onSuccess: (_data, params) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(params.partyId) })
}
}))
}
/**
* Update grid character mutation
*
* Updates a character in a party's grid with optimistic updates.
*/
export function useUpdateGridCharacter() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ id, updates }: { id: string; partyShortcode: string; updates: Partial<GridCharacter> }) =>
gridAdapter.updateCharacter(id, updates),
onMutate: async ({ id, partyShortcode, updates }) => {
await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) })
const previousParty = queryClient.getQueryData<Party>(partyKeys.detail(partyShortcode))
if (previousParty?.characters) {
const updatedCharacters = previousParty.characters.map((c) =>
c.id === id ? { ...c, ...updates } : c
)
queryClient.setQueryData(partyKeys.detail(partyShortcode), {
...previousParty,
characters: updatedCharacters
})
}
return { previousParty }
},
onError: (_err, { partyShortcode }, context) => {
if (context?.previousParty) {
queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty)
}
},
onSettled: (_data, _err, { partyShortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) })
}
}))
}
/**
* Delete grid character mutation
*
* Removes a character from a party's grid.
*/
export function useDeleteGridCharacter() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (params: { id?: string; partyId: string; partyShortcode: string; position?: number }) =>
gridAdapter.deleteCharacter({ id: params.id, partyId: params.partyId, position: params.position }),
onMutate: async ({ partyShortcode, id, position }) => {
await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) })
const previousParty = queryClient.getQueryData<Party>(partyKeys.detail(partyShortcode))
if (previousParty?.characters) {
const updatedCharacters = previousParty.characters.filter((c) =>
id ? c.id !== id : c.position !== position
)
queryClient.setQueryData(partyKeys.detail(partyShortcode), {
...previousParty,
characters: updatedCharacters
})
}
return { previousParty }
},
onError: (_err, { partyShortcode }, context) => {
if (context?.previousParty) {
queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty)
}
},
onSettled: (_data, _err, { partyShortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) })
}
}))
}
/**
* Update character uncap mutation
*
* Updates a character's uncap level with optimistic updates.
*/
export function useUpdateCharacterUncap() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (params: UpdateUncapParams & { partyShortcode: string }) =>
gridAdapter.updateCharacterUncap(params),
onMutate: async ({ partyShortcode, id, uncapLevel, transcendenceStep }) => {
await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) })
const previousParty = queryClient.getQueryData<Party>(partyKeys.detail(partyShortcode))
if (previousParty?.characters) {
const updatedCharacters = previousParty.characters.map((c) =>
c.id === id
? {
...c,
uncapLevel,
...(transcendenceStep !== undefined && { transcendenceStep })
}
: c
)
queryClient.setQueryData(partyKeys.detail(partyShortcode), {
...previousParty,
characters: updatedCharacters
})
}
return { previousParty }
},
onError: (_err, { partyShortcode }, context) => {
if (context?.previousParty) {
queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty)
}
},
onSettled: (_data, _err, { partyShortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) })
}
}))
}
/**
* Resolve character conflict mutation
*
* Resolves conflicts when adding a character that conflicts with existing characters.
*/
export function useResolveCharacterConflict() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (params: ResolveConflictParams & { partyShortcode: string }) =>
gridAdapter.resolveCharacterConflict(params),
onSuccess: (_data, { partyShortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) })
}
}))
}
// ============================================================================
// Summon Mutations
// ============================================================================
/**
* Create grid summon mutation
*
* Adds a summon to a party's grid.
*/
export function useCreateGridSummon() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (params: CreateGridSummonParams) => gridAdapter.createSummon(params),
onSuccess: (_data, params) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(params.partyId) })
}
}))
}
/**
* Update grid summon mutation
*
* Updates a summon in a party's grid with optimistic updates.
*/
export function useUpdateGridSummon() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ id, updates }: { id: string; partyShortcode: string; updates: Partial<GridSummon> }) =>
gridAdapter.updateSummon(id, updates),
onMutate: async ({ id, partyShortcode, updates }) => {
await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) })
const previousParty = queryClient.getQueryData<Party>(partyKeys.detail(partyShortcode))
if (previousParty?.summons) {
const updatedSummons = previousParty.summons.map((s) =>
s.id === id ? { ...s, ...updates } : s
)
queryClient.setQueryData(partyKeys.detail(partyShortcode), {
...previousParty,
summons: updatedSummons
})
}
return { previousParty }
},
onError: (_err, { partyShortcode }, context) => {
if (context?.previousParty) {
queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty)
}
},
onSettled: (_data, _err, { partyShortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) })
}
}))
}
/**
* Delete grid summon mutation
*
* Removes a summon from a party's grid.
*/
export function useDeleteGridSummon() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (params: { id?: string; partyId: string; partyShortcode: string; position?: number }) =>
gridAdapter.deleteSummon({ id: params.id, partyId: params.partyId, position: params.position }),
onMutate: async ({ partyShortcode, id, position }) => {
await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) })
const previousParty = queryClient.getQueryData<Party>(partyKeys.detail(partyShortcode))
if (previousParty?.summons) {
const updatedSummons = previousParty.summons.filter((s) =>
id ? s.id !== id : s.position !== position
)
queryClient.setQueryData(partyKeys.detail(partyShortcode), {
...previousParty,
summons: updatedSummons
})
}
return { previousParty }
},
onError: (_err, { partyShortcode }, context) => {
if (context?.previousParty) {
queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty)
}
},
onSettled: (_data, _err, { partyShortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) })
}
}))
}
/**
* Update summon uncap mutation
*
* Updates a summon's uncap level with optimistic updates.
*/
export function useUpdateSummonUncap() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (params: UpdateUncapParams & { partyShortcode: string }) =>
gridAdapter.updateSummonUncap(params),
onMutate: async ({ partyShortcode, id, uncapLevel, transcendenceStep }) => {
await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) })
const previousParty = queryClient.getQueryData<Party>(partyKeys.detail(partyShortcode))
if (previousParty?.summons) {
const updatedSummons = previousParty.summons.map((s) =>
s.id === id
? {
...s,
uncapLevel,
...(transcendenceStep !== undefined && { transcendenceStep })
}
: s
)
queryClient.setQueryData(partyKeys.detail(partyShortcode), {
...previousParty,
summons: updatedSummons
})
}
return { previousParty }
},
onError: (_err, { partyShortcode }, context) => {
if (context?.previousParty) {
queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty)
}
},
onSettled: (_data, _err, { partyShortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) })
}
}))
}
/**
* Update quick summon mutation
*
* Updates a summon's quick summon setting with optimistic updates.
*/
export function useUpdateQuickSummon() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (params: {
id?: string
partyId: string
partyShortcode: string
position?: number
quickSummon: boolean
}) =>
gridAdapter.updateQuickSummon({
id: params.id,
partyId: params.partyId,
position: params.position,
quickSummon: params.quickSummon
}),
onMutate: async ({ partyShortcode, id, quickSummon }) => {
await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) })
const previousParty = queryClient.getQueryData<Party>(partyKeys.detail(partyShortcode))
if (previousParty?.summons) {
const updatedSummons = previousParty.summons.map((s) =>
s.id === id ? { ...s, quickSummon } : s
)
queryClient.setQueryData(partyKeys.detail(partyShortcode), {
...previousParty,
summons: updatedSummons
})
}
return { previousParty }
},
onError: (_err, { partyShortcode }, context) => {
if (context?.previousParty) {
queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty)
}
},
onSettled: (_data, _err, { partyShortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) })
}
}))
}

View file

@ -0,0 +1,184 @@
/**
* Job Mutation Configurations
*
* Provides mutation configurations for job-related operations
* with cache invalidation using TanStack Query v6.
*
* @module api/mutations/job
*/
import { useQueryClient, createMutation } from '@tanstack/svelte-query'
import { partyAdapter } from '$lib/api/adapters/party.adapter'
import { partyKeys } from '$lib/api/queries/party.queries'
import type { Party } from '$lib/types/api/party'
/**
* Update party job mutation
*
* Updates the job for a party with optimistic updates.
*
* @example
* ```svelte
* <script lang="ts">
* import { useUpdatePartyJob } from '$lib/api/mutations/job.mutations'
*
* const updateJob = useUpdatePartyJob()
*
* function handleJobSelect(jobId: string) {
* updateJob.mutate({ shortcode: 'abc123', jobId })
* }
* </script>
* ```
*/
export function useUpdatePartyJob() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ shortcode, jobId }: { shortcode: string; jobId: string }) =>
partyAdapter.updateJob(shortcode, jobId),
onMutate: async ({ shortcode, jobId }) => {
await queryClient.cancelQueries({ queryKey: partyKeys.detail(shortcode) })
const previousParty = queryClient.getQueryData<Party>(partyKeys.detail(shortcode))
// Optimistically update the job ID
// Note: We don't have the full job object here, so we just update the ID
// The full job will be fetched when the query is invalidated
if (previousParty) {
queryClient.setQueryData(partyKeys.detail(shortcode), {
...previousParty,
job: previousParty.job ? { ...previousParty.job, id: jobId } : undefined
})
}
return { previousParty }
},
onError: (_err, { shortcode }, context) => {
if (context?.previousParty) {
queryClient.setQueryData(partyKeys.detail(shortcode), context.previousParty)
}
},
onSettled: (_data, _err, { shortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(shortcode) })
}
}))
}
/**
* Update party job skills mutation
*
* Updates the job skills for a party.
*
* @example
* ```svelte
* <script lang="ts">
* import { useUpdatePartyJobSkills } from '$lib/api/mutations/job.mutations'
*
* const updateSkills = useUpdatePartyJobSkills()
*
* function handleSkillsUpdate(skills: Array<{ id: string; slot: number }>) {
* updateSkills.mutate({ shortcode: 'abc123', skills })
* }
* </script>
* ```
*/
export function useUpdatePartyJobSkills() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({
shortcode,
skills
}: {
shortcode: string
skills: Array<{ id: string; slot: number }>
}) => partyAdapter.updateJobSkills(shortcode, skills),
onSuccess: (_data, { shortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(shortcode) })
}
}))
}
/**
* Remove party job skill mutation
*
* Removes a job skill from a party.
*
* @example
* ```svelte
* <script lang="ts">
* import { useRemovePartyJobSkill } from '$lib/api/mutations/job.mutations'
*
* const removeSkill = useRemovePartyJobSkill()
*
* function handleRemoveSkill(slot: number) {
* removeSkill.mutate({ shortcode: 'abc123', slot })
* }
* </script>
* ```
*/
export function useRemovePartyJobSkill() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ shortcode, slot }: { shortcode: string; slot: number }) =>
partyAdapter.removeJobSkill(shortcode, slot),
onMutate: async ({ shortcode, slot }) => {
await queryClient.cancelQueries({ queryKey: partyKeys.detail(shortcode) })
const previousParty = queryClient.getQueryData<Party>(partyKeys.detail(shortcode))
// Optimistically remove the skill from the slot
// Convert slot number to string key to match jobSkills type (0-3)
if (previousParty?.jobSkills) {
const updatedSkills = { ...previousParty.jobSkills }
const key = String(slot) as unknown as keyof typeof updatedSkills
delete updatedSkills[key]
queryClient.setQueryData(partyKeys.detail(shortcode), {
...previousParty,
jobSkills: updatedSkills
})
}
return { previousParty }
},
onError: (_err, { shortcode }, context) => {
if (context?.previousParty) {
queryClient.setQueryData(partyKeys.detail(shortcode), context.previousParty)
}
},
onSettled: (_data, _err, { shortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(shortcode) })
}
}))
}
/**
* Update party accessory mutation
*
* Updates the accessory for a party.
*
* @example
* ```svelte
* <script lang="ts">
* import { useUpdatePartyAccessory } from '$lib/api/mutations/job.mutations'
*
* const updateAccessory = useUpdatePartyAccessory()
*
* function handleAccessorySelect(accessoryId: string) {
* updateAccessory.mutate({ shortcode: 'abc123', accessoryId })
* }
* </script>
* ```
*/
export function useUpdatePartyAccessory() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ shortcode, accessoryId }: { shortcode: string; accessoryId: string }) =>
partyAdapter.updateAccessory(shortcode, accessoryId),
onSuccess: (_data, { shortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(shortcode) })
}
}))
}

View file

@ -0,0 +1,313 @@
/**
* Party Mutation Configurations
*
* Provides mutation configurations for party CRUD operations
* with cache invalidation and optimistic updates using TanStack Query v6.
*
* @module api/mutations/party
*/
import { useQueryClient, createMutation } from '@tanstack/svelte-query'
import {
partyAdapter,
type CreatePartyParams,
type UpdatePartyParams
} from '$lib/api/adapters/party.adapter'
import { partyKeys } from '$lib/api/queries/party.queries'
import { userKeys } from '$lib/api/queries/user.queries'
import type { Party } from '$lib/types/api/party'
/**
* Create party mutation
*
* Creates a new party and invalidates relevant caches.
*
* @example
* ```svelte
* <script lang="ts">
* import { useCreateParty } from '$lib/api/mutations/party.mutations'
*
* const createParty = useCreateParty()
*
* function handleCreate() {
* createParty.mutate({ name: 'My Party', visibility: 'public' })
* }
* </script>
* ```
*/
export function useCreateParty() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (params: CreatePartyParams) => partyAdapter.create(params),
onSuccess: (party) => {
// Set the new party in cache
queryClient.setQueryData(partyKeys.detail(party.shortcode), party)
// Invalidate party lists to include the new party
queryClient.invalidateQueries({ queryKey: partyKeys.lists() })
// Invalidate user's party lists
queryClient.invalidateQueries({ queryKey: userKeys.all })
}
}))
}
/**
* Update party mutation
*
* Updates an existing party with optimistic updates.
*
* @example
* ```svelte
* <script lang="ts">
* import { useUpdateParty } from '$lib/api/mutations/party.mutations'
*
* const updateParty = useUpdateParty()
*
* function handleUpdate() {
* updateParty.mutate({ shortcode: 'abc123', name: 'Updated Name' })
* }
* </script>
* ```
*/
export function useUpdateParty() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (params: UpdatePartyParams) => partyAdapter.update(params),
onMutate: async (params) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: partyKeys.detail(params.shortcode) })
// Snapshot the previous value
const previousParty = queryClient.getQueryData<Party>(partyKeys.detail(params.shortcode))
// Optimistically update the cache
if (previousParty) {
queryClient.setQueryData(partyKeys.detail(params.shortcode), {
...previousParty,
...params
})
}
return { previousParty }
},
onError: (_err, params, context) => {
// Rollback on error
if (context?.previousParty) {
queryClient.setQueryData(partyKeys.detail(params.shortcode), context.previousParty)
}
},
onSettled: (_data, _err, params) => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: partyKeys.detail(params.shortcode) })
}
}))
}
/**
* Delete party mutation
*
* Deletes a party and removes it from all caches.
*
* @example
* ```svelte
* <script lang="ts">
* import { useDeleteParty } from '$lib/api/mutations/party.mutations'
*
* const deleteParty = useDeleteParty()
*
* function handleDelete(shortcode: string) {
* deleteParty.mutate(shortcode)
* }
* </script>
* ```
*/
export function useDeleteParty() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (shortcode: string) => partyAdapter.delete(shortcode),
onSuccess: (_data, shortcode) => {
// Remove the party from cache
queryClient.removeQueries({ queryKey: partyKeys.detail(shortcode) })
// Invalidate party lists
queryClient.invalidateQueries({ queryKey: partyKeys.lists() })
// Invalidate user's party lists
queryClient.invalidateQueries({ queryKey: userKeys.all })
}
}))
}
/**
* Remix party mutation
*
* Creates a copy of an existing party.
*
* @example
* ```svelte
* <script lang="ts">
* import { useRemixParty } from '$lib/api/mutations/party.mutations'
*
* const remixParty = useRemixParty()
*
* function handleRemix(shortcode: string) {
* remixParty.mutate(shortcode)
* }
* </script>
* ```
*/
export function useRemixParty() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (shortcode: string) => partyAdapter.remix(shortcode),
onSuccess: (newParty) => {
// Set the new party in cache
queryClient.setQueryData(partyKeys.detail(newParty.shortcode), newParty)
// Invalidate party lists to include the new party
queryClient.invalidateQueries({ queryKey: partyKeys.lists() })
// Invalidate user's party lists
queryClient.invalidateQueries({ queryKey: userKeys.all })
}
}))
}
/**
* Favorite party mutation
*
* Adds a party to the user's favorites.
*
* @example
* ```svelte
* <script lang="ts">
* import { useFavoriteParty } from '$lib/api/mutations/party.mutations'
*
* const favoriteParty = useFavoriteParty()
*
* function handleFavorite(shortcode: string) {
* favoriteParty.mutate(shortcode)
* }
* </script>
* ```
*/
export function useFavoriteParty() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (shortcode: string) => partyAdapter.favorite(shortcode),
onMutate: async (shortcode) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: partyKeys.detail(shortcode) })
// Snapshot the previous value
const previousParty = queryClient.getQueryData<Party>(partyKeys.detail(shortcode))
// Optimistically update the cache
if (previousParty) {
queryClient.setQueryData(partyKeys.detail(shortcode), {
...previousParty,
favorited: true
})
}
return { previousParty }
},
onError: (_err, shortcode, context) => {
// Rollback on error
if (context?.previousParty) {
queryClient.setQueryData(partyKeys.detail(shortcode), context.previousParty)
}
},
onSettled: (_data, _err, shortcode) => {
// Invalidate favorites list
queryClient.invalidateQueries({ queryKey: userKeys.favorites() })
// Refetch the party to get accurate state
queryClient.invalidateQueries({ queryKey: partyKeys.detail(shortcode) })
}
}))
}
/**
* Unfavorite party mutation
*
* Removes a party from the user's favorites.
*
* @example
* ```svelte
* <script lang="ts">
* import { useUnfavoriteParty } from '$lib/api/mutations/party.mutations'
*
* const unfavoriteParty = useUnfavoriteParty()
*
* function handleUnfavorite(shortcode: string) {
* unfavoriteParty.mutate(shortcode)
* }
* </script>
* ```
*/
export function useUnfavoriteParty() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (shortcode: string) => partyAdapter.unfavorite(shortcode),
onMutate: async (shortcode) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: partyKeys.detail(shortcode) })
// Snapshot the previous value
const previousParty = queryClient.getQueryData<Party>(partyKeys.detail(shortcode))
// Optimistically update the cache
if (previousParty) {
queryClient.setQueryData(partyKeys.detail(shortcode), {
...previousParty,
favorited: false
})
}
return { previousParty }
},
onError: (_err, shortcode, context) => {
// Rollback on error
if (context?.previousParty) {
queryClient.setQueryData(partyKeys.detail(shortcode), context.previousParty)
}
},
onSettled: (_data, _err, shortcode) => {
// Invalidate favorites list
queryClient.invalidateQueries({ queryKey: userKeys.favorites() })
// Refetch the party to get accurate state
queryClient.invalidateQueries({ queryKey: partyKeys.detail(shortcode) })
}
}))
}
/**
* Regenerate preview mutation
*
* Triggers regeneration of a party's preview image.
*
* @example
* ```svelte
* <script lang="ts">
* import { useRegeneratePreview } from '$lib/api/mutations/party.mutations'
*
* const regeneratePreview = useRegeneratePreview()
*
* function handleRegenerate(shortcode: string) {
* regeneratePreview.mutate(shortcode)
* }
* </script>
* ```
*/
export function useRegeneratePreview() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (shortcode: string) => partyAdapter.regeneratePreview(shortcode),
onSuccess: (_data, shortcode) => {
// Invalidate preview status to trigger refetch
queryClient.invalidateQueries({ queryKey: partyKeys.preview(shortcode) })
}
}))
}

View file

@ -0,0 +1,182 @@
/**
* Job Query Options Factory
*
* Provides type-safe, reusable query configurations for job operations
* using TanStack Query v6 patterns.
*
* @module api/queries/job
*/
import { queryOptions, infiniteQueryOptions } from '@tanstack/svelte-query'
import {
jobAdapter,
type SearchJobSkillsParams
} from '$lib/api/adapters/job.adapter'
import type { Job, JobSkill, JobAccessory } from '$lib/types/api/entities'
/**
* Standard page result format for job skill infinite queries
*/
export interface JobSkillPageResult {
results: JobSkill[]
page: number
totalPages: number
total: number
}
/**
* Job query options factory
*
* Provides query configurations for all job-related operations.
* These can be used with `createQuery`, `createInfiniteQuery`, or for prefetching.
*
* @example
* ```typescript
* import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query'
* import { jobQueries } from '$lib/api/queries/job.queries'
*
* // All jobs
* const jobs = createQuery(() => jobQueries.list())
*
* // Skills for a specific job with infinite scroll
* const skills = createInfiniteQuery(() => jobQueries.skills(jobId, { query: searchTerm }))
* ```
*/
export const jobQueries = {
/**
* All jobs list query options
*
* @returns Query options for fetching all jobs
*/
list: () =>
queryOptions({
queryKey: ['jobs'] as const,
queryFn: () => jobAdapter.getAll(),
staleTime: 1000 * 60 * 30, // 30 minutes - jobs rarely change
gcTime: 1000 * 60 * 60 // 1 hour
}),
/**
* Single job query options
*
* @param id - Job ID
* @returns Query options for fetching a single job
*/
byId: (id: string) =>
queryOptions({
queryKey: ['jobs', id] as const,
queryFn: () => jobAdapter.getById(id),
enabled: !!id,
staleTime: 1000 * 60 * 30, // 30 minutes
gcTime: 1000 * 60 * 60 // 1 hour
}),
/**
* Job skills query options (non-paginated)
*
* @param jobId - Job ID to fetch skills for
* @returns Query options for fetching all skills for a job
*/
skillsByJob: (jobId: string) =>
queryOptions({
queryKey: ['jobs', jobId, 'skills'] as const,
queryFn: () => jobAdapter.getSkills(jobId),
enabled: !!jobId,
staleTime: 1000 * 60 * 30, // 30 minutes
gcTime: 1000 * 60 * 60 // 1 hour
}),
/**
* Job skills search infinite query options
*
* @param jobId - Job ID to search skills for
* @param params - Optional search parameters (query, filters, locale)
* @returns Infinite query options for searching job skills
*/
skills: (
jobId: string,
params?: Omit<SearchJobSkillsParams, 'jobId' | 'page'>
) =>
infiniteQueryOptions({
queryKey: ['jobs', jobId, 'skills', 'search', params] as const,
queryFn: async ({ pageParam }): Promise<JobSkillPageResult> => {
const response = await jobAdapter.searchSkills({
jobId,
...params,
page: pageParam
})
return {
results: response.results,
page: response.page,
totalPages: response.totalPages,
total: response.total
}
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
if (lastPage.page < lastPage.totalPages) {
return lastPage.page + 1
}
return undefined
},
enabled: !!jobId,
staleTime: 1000 * 60 * 5, // 5 minutes - search results can change
gcTime: 1000 * 60 * 15 // 15 minutes
}),
/**
* Job accessories query options
*
* @param jobId - Job ID to fetch accessories for
* @returns Query options for fetching job accessories
*/
accessories: (jobId: string) =>
queryOptions({
queryKey: ['jobs', jobId, 'accessories'] as const,
queryFn: () => jobAdapter.getAccessories(jobId),
enabled: !!jobId,
staleTime: 1000 * 60 * 30, // 30 minutes
gcTime: 1000 * 60 * 60 // 1 hour
}),
/**
* All job skills query options (not filtered by job)
*
* @returns Query options for fetching all job skills
*/
allSkills: () =>
queryOptions({
queryKey: ['jobs', 'skills', 'all'] as const,
queryFn: () => jobAdapter.getAllSkills(),
staleTime: 1000 * 60 * 30, // 30 minutes
gcTime: 1000 * 60 * 60 // 1 hour
})
}
/**
* Query key helpers for cache invalidation
*
* @example
* ```typescript
* import { useQueryClient } from '@tanstack/svelte-query'
* import { jobKeys } from '$lib/api/queries/job.queries'
*
* const queryClient = useQueryClient()
*
* // Invalidate all jobs
* queryClient.invalidateQueries({ queryKey: jobKeys.all })
*
* // Invalidate skills for a specific job
* queryClient.invalidateQueries({ queryKey: jobKeys.skills('job-id') })
* ```
*/
export const jobKeys = {
all: ['jobs'] as const,
lists: () => [...jobKeys.all] as const,
detail: (id: string) => [...jobKeys.all, id] as const,
skills: (jobId: string) => [...jobKeys.all, jobId, 'skills'] as const,
skillsSearch: (jobId: string, params?: Omit<SearchJobSkillsParams, 'jobId' | 'page'>) =>
[...jobKeys.skills(jobId), 'search', params] as const,
accessories: (jobId: string) => [...jobKeys.all, jobId, 'accessories'] as const,
allSkills: () => [...jobKeys.all, 'skills', 'all'] as const
}

View file

@ -0,0 +1,185 @@
/**
* Party Query Options Factory
*
* Provides type-safe, reusable query configurations for party operations
* using TanStack Query v6 patterns.
*
* @module api/queries/party
*/
import { queryOptions, infiniteQueryOptions } from '@tanstack/svelte-query'
import {
partyAdapter,
type ListUserPartiesParams
} from '$lib/api/adapters/party.adapter'
import type { Party } from '$lib/types/api/party'
/**
* Standard page result format for infinite queries
*/
export interface PartyPageResult {
results: Party[]
page: number
totalPages: number
total?: number
perPage?: number
}
/**
* Parameters for listing parties
*/
export interface ListPartiesParams {
page?: number
per?: number
}
/**
* Party query options factory
*
* Provides query configurations for all party-related operations.
* These can be used with `createQuery`, `createInfiniteQuery`, or for prefetching.
*
* @example
* ```typescript
* import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query'
* import { partyQueries } from '$lib/api/queries/party.queries'
*
* // Single party by shortcode
* const party = createQuery(() => partyQueries.byShortcode(shortcode))
*
* // Infinite list of parties
* const parties = createInfiniteQuery(() => partyQueries.list())
* ```
*/
export const partyQueries = {
/**
* Single party query options
*
* @param shortcode - Party shortcode identifier
* @returns Query options for fetching a single party
*/
byShortcode: (shortcode: string) =>
queryOptions({
queryKey: ['party', shortcode] as const,
queryFn: () => partyAdapter.getByShortcode(shortcode),
enabled: !!shortcode,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30 // 30 minutes
}),
/**
* Public parties list (explore page) infinite query options
*
* @param params - Optional pagination parameters
* @returns Infinite query options for listing public parties
*/
list: (params?: Omit<ListPartiesParams, 'page'>) =>
infiniteQueryOptions({
queryKey: ['parties', 'list', params] as const,
queryFn: async ({ pageParam }): Promise<PartyPageResult> => {
const response = await partyAdapter.list({
...params,
page: pageParam
})
return {
results: response.results,
page: response.page,
totalPages: response.totalPages,
total: response.total,
perPage: response.perPage
}
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
if (lastPage.page < lastPage.totalPages) {
return lastPage.page + 1
}
return undefined
},
staleTime: 1000 * 60 * 2, // 2 minutes - parties change more frequently
gcTime: 1000 * 60 * 15 // 15 minutes
}),
/**
* User parties list infinite query options
*
* @param username - Username to fetch parties for
* @param params - Optional filter parameters
* @returns Infinite query options for listing user's parties
*/
userParties: (
username: string,
params?: Omit<ListUserPartiesParams, 'username' | 'page'>
) =>
infiniteQueryOptions({
queryKey: ['parties', 'user', username, params] as const,
queryFn: async ({ pageParam }): Promise<PartyPageResult> => {
const response = await partyAdapter.listUserParties({
username,
...params,
page: pageParam
})
return {
results: response.results,
page: response.page,
totalPages: response.totalPages,
total: response.total,
perPage: response.perPage
}
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
if (lastPage.page < lastPage.totalPages) {
return lastPage.page + 1
}
return undefined
},
enabled: !!username,
staleTime: 1000 * 60 * 2, // 2 minutes
gcTime: 1000 * 60 * 15 // 15 minutes
}),
/**
* Party preview status query options
*
* @param shortcode - Party shortcode identifier
* @returns Query options for fetching party preview status
*/
previewStatus: (shortcode: string) =>
queryOptions({
queryKey: ['party', shortcode, 'preview'] as const,
queryFn: () => partyAdapter.getPreviewStatus(shortcode),
enabled: !!shortcode,
staleTime: 1000 * 30, // 30 seconds - preview status changes
gcTime: 1000 * 60 * 5 // 5 minutes
})
}
/**
* Query key helpers for cache invalidation
*
* @example
* ```typescript
* import { useQueryClient } from '@tanstack/svelte-query'
* import { partyKeys } from '$lib/api/queries/party.queries'
*
* const queryClient = useQueryClient()
*
* // Invalidate a specific party
* queryClient.invalidateQueries({ queryKey: partyKeys.detail('abc123') })
*
* // Invalidate all party lists
* queryClient.invalidateQueries({ queryKey: partyKeys.lists() })
* ```
*/
export const partyKeys = {
all: ['parties'] as const,
lists: () => [...partyKeys.all, 'list'] as const,
list: (params?: ListPartiesParams) => [...partyKeys.lists(), params] as const,
userLists: () => [...partyKeys.all, 'user'] as const,
userList: (username: string, params?: Omit<ListUserPartiesParams, 'username'>) =>
[...partyKeys.userLists(), username, params] as const,
details: () => ['party'] as const,
detail: (shortcode: string) => [...partyKeys.details(), shortcode] as const,
preview: (shortcode: string) => [...partyKeys.detail(shortcode), 'preview'] as const
}

View file

@ -0,0 +1,218 @@
/**
* User Query Options Factory
*
* Provides type-safe, reusable query configurations for user operations
* using TanStack Query v6 patterns.
*
* @module api/queries/user
*/
import { queryOptions, infiniteQueryOptions } from '@tanstack/svelte-query'
import { userAdapter, type UserInfo, type UserProfile } from '$lib/api/adapters/user.adapter'
import type { Party } from '$lib/types/api/party'
/**
* Standard page result format for user parties infinite queries
*/
export interface UserPartiesPageResult {
results: Party[]
page: number
totalPages: number
total: number
perPage: number
}
/**
* Standard page result format for favorites infinite queries
*/
export interface FavoritesPageResult {
items: Party[]
page: number
totalPages: number
total: number
perPage: number
}
/**
* User query options factory
*
* Provides query configurations for all user-related operations.
* These can be used with `createQuery`, `createInfiniteQuery`, or for prefetching.
*
* @example
* ```typescript
* import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query'
* import { userQueries } from '$lib/api/queries/user.queries'
*
* // Current user
* const currentUser = createQuery(() => userQueries.me())
*
* // User profile with parties
* const profile = createQuery(() => userQueries.profile(username))
*
* // User's parties with infinite scroll
* const parties = createInfiniteQuery(() => userQueries.parties(username))
* ```
*/
export const userQueries = {
/**
* Current user query options
*
* @returns Query options for fetching the current authenticated user
*/
me: () =>
queryOptions({
queryKey: ['user', 'me'] as const,
queryFn: () => userAdapter.getCurrentUser(),
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30 // 30 minutes
}),
/**
* User info query options
*
* @param username - Username to fetch info for
* @returns Query options for fetching user info
*/
info: (username: string) =>
queryOptions({
queryKey: ['user', username, 'info'] as const,
queryFn: () => userAdapter.getInfo(username),
enabled: !!username,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30 // 30 minutes
}),
/**
* User profile query options (includes first page of parties)
*
* @param username - Username to fetch profile for
* @returns Query options for fetching user profile
*/
profile: (username: string) =>
queryOptions({
queryKey: ['user', username, 'profile'] as const,
queryFn: () => userAdapter.getProfile(username),
enabled: !!username,
staleTime: 1000 * 60 * 2, // 2 minutes - profile data changes
gcTime: 1000 * 60 * 15 // 15 minutes
}),
/**
* User parties infinite query options
*
* @param username - Username to fetch parties for
* @returns Infinite query options for fetching user's parties
*/
parties: (username: string) =>
infiniteQueryOptions({
queryKey: ['user', username, 'parties'] as const,
queryFn: async ({ pageParam }): Promise<UserPartiesPageResult> => {
const response = await userAdapter.getProfileParties(username, pageParam)
return {
results: response.results,
page: response.page,
totalPages: response.totalPages,
total: response.total,
perPage: response.perPage
}
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
if (lastPage.page < lastPage.totalPages) {
return lastPage.page + 1
}
return undefined
},
enabled: !!username,
staleTime: 1000 * 60 * 2, // 2 minutes
gcTime: 1000 * 60 * 15 // 15 minutes
}),
/**
* User favorites infinite query options
*
* @returns Infinite query options for fetching user's favorite parties
*/
favorites: () =>
infiniteQueryOptions({
queryKey: ['user', 'favorites'] as const,
queryFn: async ({ pageParam }): Promise<FavoritesPageResult> => {
const response = await userAdapter.getFavorites({ page: pageParam })
return {
items: response.items,
page: response.page,
totalPages: response.totalPages,
total: response.total,
perPage: response.perPage
}
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
if (lastPage.page < lastPage.totalPages) {
return lastPage.page + 1
}
return undefined
},
staleTime: 1000 * 60 * 2, // 2 minutes
gcTime: 1000 * 60 * 15 // 15 minutes
}),
/**
* Username availability check query options
*
* @param username - Username to check availability for
* @returns Query options for checking username availability
*/
checkUsername: (username: string) =>
queryOptions({
queryKey: ['user', 'check', 'username', username] as const,
queryFn: () => userAdapter.checkUsernameAvailability(username),
enabled: !!username && username.length >= 3,
staleTime: 1000 * 30, // 30 seconds - availability can change
gcTime: 1000 * 60 * 5 // 5 minutes
}),
/**
* Email availability check query options
*
* @param email - Email to check availability for
* @returns Query options for checking email availability
*/
checkEmail: (email: string) =>
queryOptions({
queryKey: ['user', 'check', 'email', email] as const,
queryFn: () => userAdapter.checkEmailAvailability(email),
enabled: !!email && email.includes('@'),
staleTime: 1000 * 30, // 30 seconds - availability can change
gcTime: 1000 * 60 * 5 // 5 minutes
})
}
/**
* Query key helpers for cache invalidation
*
* @example
* ```typescript
* import { useQueryClient } from '@tanstack/svelte-query'
* import { userKeys } from '$lib/api/queries/user.queries'
*
* const queryClient = useQueryClient()
*
* // Invalidate current user
* queryClient.invalidateQueries({ queryKey: userKeys.me() })
*
* // Invalidate a user's profile
* queryClient.invalidateQueries({ queryKey: userKeys.profile('username') })
* ```
*/
export const userKeys = {
all: ['user'] as const,
me: () => [...userKeys.all, 'me'] as const,
info: (username: string) => [...userKeys.all, username, 'info'] as const,
profile: (username: string) => [...userKeys.all, username, 'profile'] as const,
parties: (username: string) => [...userKeys.all, username, 'parties'] as const,
favorites: () => [...userKeys.all, 'favorites'] as const,
checkUsername: (username: string) => [...userKeys.all, 'check', 'username', username] as const,
checkEmail: (email: string) => [...userKeys.all, 'check', 'email', email] as const
}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte'
import { createQuery } from '@tanstack/svelte-query'
import type { Job } from '$lib/types/api/entities'
import { createJobResource } from '$lib/api/adapters/resources/job.resource.svelte'
import { jobQueries } from '$lib/api/queries/job.queries'
import { getJobTierName, getJobTierOrder } from '$lib/utils/jobUtils'
import JobItem from '../job/JobItem.svelte'
import JobTierSelector from '../job/JobTierSelector.svelte'
@ -17,14 +17,13 @@
let { currentJobId, onSelectJob }: Props = $props()
// Create job resource
const jobResource = createJobResource()
// TanStack Query v6: Use createQuery with thunk pattern for reactivity
// Jobs are cached for 30 minutes and shared across all components
const jobsQuery = createQuery(() => jobQueries.list())
// State
// State for filtering (local UI state, not server state)
let searchQuery = $state('')
let selectedTiers = $state<Set<string>>(new Set(['4', '5', 'ex2', 'o1'])) // Default to IV, V, EXII, OI
let loading = $state(false)
let error = $state<string | undefined>()
// Available tiers with short labels for display
const tiers = [
@ -48,29 +47,11 @@
selectedTiers = newSet
}
// Fetch jobs on mount
onMount(() => {
loadJobs()
})
async function loadJobs() {
loading = true
error = undefined
try {
await jobResource.fetchJobs()
} catch (e: any) {
error = e.message || 'Failed to load jobs'
console.error('Error loading jobs:', e)
} finally {
loading = false
}
}
// Filter jobs based on search and filters
// TanStack Query handles loading/error states, we just filter the data
const filteredJobs = $derived(
(() => {
let jobs = jobResource.jobs.data || []
let jobs = jobsQuery.data || []
// Filter by search query
if (searchQuery) {
@ -135,16 +116,16 @@
</div>
<div class="jobs-container">
{#if loading}
{#if jobsQuery.isLoading}
<div class="loading-state">
<Icon name="loader-2" size={32} />
<p>Loading jobs...</p>
</div>
{:else if error}
{:else if jobsQuery.isError}
<div class="error-state">
<Icon name="alert-circle" size={32} />
<p>{error}</p>
<Button size="small" onclick={loadJobs}>Retry</Button>
<p>{jobsQuery.error?.message || 'Failed to load jobs'}</p>
<Button size="small" onclick={() => jobsQuery.refetch()}>Retry</Button>
</div>
{:else if Object.keys(filteredJobs).length === 0}
<div class="empty-state">

217
src/lib/query/MIGRATION.md Normal file
View file

@ -0,0 +1,217 @@
# TanStack Query Migration Guide
This document contains follow-up prompts for migrating remaining components to TanStack Query v6.
## Migration Status
### Completed (PR #441)
- Query options factories: `party.queries.ts`, `job.queries.ts`, `user.queries.ts`
- Mutation configurations: `party.mutations.ts`, `grid.mutations.ts`, `job.mutations.ts`
- SSR utilities: `withInitialData`, `prefetchQuery`, `prefetchInfiniteQuery`
- Example components: `JobSelectionSidebar.svelte`, `teams/[id]/+page.svelte`
### Pending Migration
The following components still use direct adapter calls or resource classes and should be migrated in future PRs.
---
## Follow-Up Prompt 1: Job Skill Selection Sidebar
**Scope**: Migrate `JobSkillSelectionSidebar.svelte` to use TanStack Query
**Prompt**:
```
Migrate the JobSkillSelectionSidebar component to use TanStack Query v6.
The component currently uses InfiniteScrollResource for paginated skill loading.
Replace it with createInfiniteQuery using jobQueries.skills().
Files to modify:
- src/lib/components/sidebar/JobSkillSelectionSidebar.svelte
Reference implementation:
- src/lib/components/sidebar/JobSelectionSidebar.svelte (already migrated)
- src/lib/api/queries/job.queries.ts (jobQueries.skills for infinite query)
Key changes:
1. Replace InfiniteScrollResource with createInfiniteQuery
2. Use jobQueries.skills(jobId, { query: searchTerm }) for the query options
3. Handle pagination with query.fetchNextPage() and query.hasNextPage
4. Update loading/error states to use query.isLoading, query.isError
```
---
## Follow-Up Prompt 2: Search Modal Migration
**Scope**: Migrate search functionality to use TanStack Query
**Prompt**:
```
Migrate the search functionality to use TanStack Query v6.
The existing search.queries.ts has infinite query options for weapons, characters,
summons, and job skills. Wire these up to the actual search components.
Files to modify:
- src/lib/components/search/SearchPanel.svelte (or equivalent)
- src/lib/features/search/openSearchSidebar.svelte.ts
Reference:
- src/lib/api/queries/search.queries.ts (existing query options)
- src/lib/components/InfiniteScrollQuery.svelte (existing TanStack Query component)
Key changes:
1. Use createInfiniteQuery with searchQueries.weapons/characters/summons
2. Implement debounced search input (debounce the value, not the query)
3. Use InfiniteScrollQuery component for rendering results
4. Remove dependency on SearchResource class
```
---
## Follow-Up Prompt 3: User Profile Page
**Scope**: Migrate user profile page to use TanStack Query with SSR
**Prompt**:
```
Migrate the [username]/+page.svelte to use TanStack Query v6 with SSR.
The page currently fetches user profile and parties in +page.server.ts.
Add TanStack Query integration using the withInitialData pattern.
Files to modify:
- src/routes/[username]/+page.svelte
Reference:
- src/routes/teams/[id]/+page.svelte (already migrated with withInitialData)
- src/lib/api/queries/user.queries.ts (userQueries.profile, userQueries.parties)
- src/lib/query/ssr.ts (withInitialData helper)
Key changes:
1. Add createQuery for user profile with withInitialData
2. Add createInfiniteQuery for user parties with initialData from server
3. Use $derived to prefer query data over server data
4. Enable background refetching for fresh data
```
---
## Follow-Up Prompt 4: Teams Explore Page
**Scope**: Migrate teams explore page to use TanStack Query
**Prompt**:
```
Migrate the teams/explore page to use TanStack Query v6 for party listing.
The page displays a paginated list of public parties with filtering.
Files to modify:
- src/routes/teams/explore/+page.svelte
- src/routes/teams/explore/+page.server.ts (if needed)
Reference:
- src/lib/api/queries/party.queries.ts (partyQueries.list for infinite query)
- src/lib/query/ssr.ts (prefetchInfiniteQuery for SSR)
Key changes:
1. Use createInfiniteQuery with partyQueries.list(filters)
2. Implement filter state that triggers query refetch
3. Use infinite scroll or "Load More" button with query.fetchNextPage()
4. Consider prefetching first page in +page.ts for faster initial load
```
---
## Follow-Up Prompt 5: Party Component Mutations
**Scope**: Wire up Party component to use TanStack Query mutations
**Prompt**:
```
Migrate the Party component to use TanStack Query mutations for CRUD operations.
The Party component currently uses PartyService and GridService directly.
Replace these with TanStack Query mutations for automatic cache invalidation.
Files to modify:
- src/lib/components/party/Party.svelte
Reference:
- src/lib/api/mutations/party.mutations.ts (useUpdateParty, useDeleteParty, etc.)
- src/lib/api/mutations/grid.mutations.ts (useUpdateGridWeapon, etc.)
- src/lib/api/mutations/job.mutations.ts (useUpdateJob, useUpdateSkills)
Key changes:
1. Import and use mutation hooks (useUpdateParty, useDeleteParty, etc.)
2. Replace direct service calls with mutation.mutate()
3. Leverage optimistic updates for immediate UI feedback
4. Use mutation.isPending for loading states
5. Use mutation.error for error handling
Note: This is a larger refactor. Consider breaking it into sub-tasks:
- 5a: Party metadata mutations (name, description, visibility)
- 5b: Grid weapon mutations
- 5c: Grid character mutations
- 5d: Grid summon mutations
- 5e: Job and skill mutations
```
---
## Follow-Up Prompt 6: Remove Deprecated Resource Classes
**Scope**: Remove deprecated resource classes after migration is complete
**Prompt**:
```
Remove the deprecated resource classes now that TanStack Query migration is complete.
Files to delete:
- src/lib/api/adapters/resources/search.resource.svelte.ts
- src/lib/api/adapters/resources/party.resource.svelte.ts
- src/lib/api/adapters/resources/job.resource.svelte.ts
- src/lib/api/adapters/resources/infiniteScroll.resource.svelte.ts
Pre-requisites:
1. Verify no components import these files (use grep)
2. Ensure all functionality has been migrated to TanStack Query
3. Run build to confirm no import errors
Steps:
1. Search for any remaining imports of resource classes
2. Migrate any remaining usages to TanStack Query
3. Delete the resource files
4. Update any barrel exports (index.ts files)
5. Run build and tests to verify
```
---
## Migration Checklist
Use this checklist to track overall migration progress:
- [x] Create query options factories
- [x] Create mutation configurations
- [x] Add SSR utilities
- [x] Migrate JobSelectionSidebar (example)
- [x] Migrate teams/[id] page (SSR example)
- [ ] Migrate JobSkillSelectionSidebar
- [ ] Migrate search functionality
- [ ] Migrate user profile page
- [ ] Migrate teams explore page
- [ ] Migrate Party component mutations
- [ ] Remove deprecated resource classes
- [ ] Add TanStack Query devtools (optional)
## Notes
- Always test locally before pushing changes
- Run `npm run build` to verify TypeScript compilation
- The existing adapters remain unchanged - TanStack Query wraps them
- Cache invalidation is handled automatically by mutations
- SSR uses hybrid approach: existing +page.server.ts + withInitialData pattern

119
src/lib/query/README.md Normal file
View file

@ -0,0 +1,119 @@
# TanStack Query SSR Integration
This directory contains utilities for integrating TanStack Query v6 with SvelteKit's server-side rendering.
## Architecture Overview
The project uses a hybrid approach for SSR:
1. **QueryClient in Layout**: The `QueryClient` is created in `+layout.ts` and passed to `+layout.svelte` via the load function. This enables prefetching in child page load functions.
2. **Server Data with initialData**: Pages that use `+page.server.ts` can pass server-fetched data as `initialData` to TanStack Query using the `withInitialData()` helper.
3. **Prefetching in +page.ts**: Pages that use `+page.ts` (universal load functions) can use `prefetchQuery()` to populate the QueryClient cache before rendering.
## Usage Examples
### Pattern 1: Using Server Data with initialData
For pages that already fetch data in `+page.server.ts`:
```svelte
<!-- +page.svelte -->
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query'
import { partyQueries } from '$lib/api/queries/party.queries'
import { withInitialData } from '$lib/query/ssr'
import type { PageData } from './$types'
let { data } = $props<{ data: PageData }>()
// Use server-fetched party as initial data
// The query won't refetch until the data becomes stale
const party = createQuery(() => ({
...partyQueries.byShortcode(data.party?.shortcode ?? ''),
...withInitialData(data.party),
enabled: !!data.party?.shortcode
}))
</script>
{#if $party.data}
<h1>{$party.data.name}</h1>
{/if}
```
### Pattern 2: Prefetching in Universal Load Functions
For pages that can use `+page.ts` (not server-only):
```typescript
// +page.ts
import type { PageLoad } from './$types'
import { prefetchQuery } from '$lib/query/ssr'
import { partyQueries } from '$lib/api/queries/party.queries'
export const load: PageLoad = async ({ parent, params }) => {
const { queryClient } = await parent()
// Prefetch party data into the cache
await prefetchQuery(queryClient, partyQueries.byShortcode(params.id))
// No need to return data - it's already in the QueryClient cache
return { shortcode: params.id }
}
```
```svelte
<!-- +page.svelte -->
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query'
import { partyQueries } from '$lib/api/queries/party.queries'
import type { PageData } from './$types'
let { data } = $props<{ data: PageData }>()
// Data is already in cache from prefetch - no loading state on initial render
const party = createQuery(() => partyQueries.byShortcode(data.shortcode))
</script>
{#if $party.data}
<h1>{$party.data.name}</h1>
{/if}
```
### Pattern 3: Infinite Queries with Prefetching
For paginated data:
```typescript
// +page.ts
import type { PageLoad } from './$types'
import { prefetchInfiniteQuery } from '$lib/query/ssr'
import { partyQueries } from '$lib/api/queries/party.queries'
export const load: PageLoad = async ({ parent }) => {
const { queryClient } = await parent()
// Prefetch first page of parties
await prefetchInfiniteQuery(queryClient, partyQueries.list())
}
```
## Migration Guide
### From Server-Only to TanStack Query
1. **Keep existing +page.server.ts** - No changes needed to server load functions
2. **Add TanStack Query to component** - Use `createQuery` with `withInitialData`
3. **Benefit from caching** - Subsequent navigations use cached data
### From Custom Resources to TanStack Query
1. **Replace resource imports** with query/mutation imports
2. **Use createQuery** instead of resource state
3. **Use mutations** for CRUD operations with automatic cache invalidation
## Files
- `queryClient.ts` - QueryClient factory (legacy, kept for reference)
- `ssr.ts` - SSR utilities (withInitialData, prefetchQuery, etc.)

190
src/lib/query/ssr.ts Normal file
View file

@ -0,0 +1,190 @@
/**
* SSR Utilities for TanStack Query
*
* Provides utilities for integrating server-side data fetching with TanStack Query.
* These utilities support the initialData pattern for pages that use +page.server.ts
* load functions.
*
* @module query/ssr
*/
import type { QueryClient } from '@tanstack/svelte-query'
/**
* Options for creating a query with initial data from SSR
*/
export interface InitialDataOptions<TData> {
/**
* The data fetched on the server to use as initial data.
* TanStack Query accepts TData | undefined but NOT null.
*/
initialData?: TData
/**
* Optional timestamp when the data was fetched on the server.
* If not provided, defaults to 0 (will be considered stale immediately).
* Use Date.now() on the server to get accurate timestamps.
*/
initialDataUpdatedAt?: number
}
/**
* Creates query options with initial data from server-side rendering.
*
* Use this helper when you have data fetched in a +page.server.ts load function
* and want to use it as initial data for a TanStack Query.
*
* Note: This helper strips `null` from the input since TanStack Query's
* initialData only accepts `TData | undefined`, not `null`.
*
* @example
* ```svelte
* <script lang="ts">
* import { createQuery } from '@tanstack/svelte-query'
* import { partyQueries } from '$lib/api/queries/party.queries'
* import { withInitialData } from '$lib/query/ssr'
* import type { PageData } from './$types'
*
* let { data } = $props<{ data: PageData }>()
*
* // Use server-fetched party as initial data
* const party = createQuery(() => ({
* ...partyQueries.byShortcode(data.party?.shortcode ?? ''),
* ...withInitialData(data.party)
* }))
* </script>
* ```
*
* @param initialData - The data fetched on the server (null is converted to undefined)
* @param updatedAt - Optional timestamp when data was fetched (defaults to 0)
* @returns Query options object with initialData and initialDataUpdatedAt
*/
export function withInitialData<TData>(
initialData: TData | undefined | null,
updatedAt?: number
): InitialDataOptions<NonNullable<TData>> {
return {
initialData: (initialData ?? undefined) as NonNullable<TData> | undefined,
initialDataUpdatedAt: updatedAt ?? 0
}
}
/**
* Prefetches a query on the server and returns the data.
*
* Use this in +page.ts load functions when you want to prefetch data
* into the QueryClient cache. This is the recommended approach for
* pages that don't use +page.server.ts.
*
* Note: This will NOT work with +page.server.ts load functions.
* Use withInitialData() instead for server-only load functions.
*
* @example
* ```typescript
* // +page.ts
* import type { PageLoad } from './$types'
* import { prefetchQuery } from '$lib/query/ssr'
* import { partyQueries } from '$lib/api/queries/party.queries'
*
* export const load: PageLoad = async ({ parent, params }) => {
* const { queryClient } = await parent()
*
* await prefetchQuery(queryClient, partyQueries.byShortcode(params.id))
*
* // No need to return data - it's in the cache
* }
* ```
*
* @param queryClient - The QueryClient instance from parent layout
* @param options - Query options from a query factory
*/
export async function prefetchQuery<TData>(
queryClient: QueryClient,
options: {
queryKey: readonly unknown[]
queryFn: () => Promise<TData>
staleTime?: number
gcTime?: number
}
): Promise<void> {
await queryClient.prefetchQuery({
queryKey: options.queryKey,
queryFn: options.queryFn,
staleTime: options.staleTime,
gcTime: options.gcTime
})
}
/**
* Prefetches an infinite query on the server.
*
* Use this in +page.ts load functions when you want to prefetch
* paginated data into the QueryClient cache.
*
* @example
* ```typescript
* // +page.ts
* import type { PageLoad } from './$types'
* import { prefetchInfiniteQuery } from '$lib/query/ssr'
* import { partyQueries } from '$lib/api/queries/party.queries'
*
* export const load: PageLoad = async ({ parent }) => {
* const { queryClient } = await parent()
*
* await prefetchInfiniteQuery(queryClient, partyQueries.list())
* }
* ```
*
* @param queryClient - The QueryClient instance from parent layout
* @param options - Infinite query options from a query factory
*/
export async function prefetchInfiniteQuery<TData>(
queryClient: QueryClient,
options: {
queryKey: readonly unknown[]
queryFn: (context: { pageParam: number }) => Promise<TData>
initialPageParam: number
staleTime?: number
gcTime?: number
}
): Promise<void> {
await queryClient.prefetchInfiniteQuery({
queryKey: options.queryKey,
queryFn: options.queryFn,
initialPageParam: options.initialPageParam,
staleTime: options.staleTime,
gcTime: options.gcTime
})
}
/**
* Sets query data directly in the cache.
*
* Use this when you have data from a server load function and want
* to populate the QueryClient cache directly. This is useful when
* migrating from server-only load functions to TanStack Query.
*
* @example
* ```typescript
* // In a component or effect
* import { useQueryClient } from '@tanstack/svelte-query'
* import { setQueryData } from '$lib/query/ssr'
* import { partyKeys } from '$lib/api/queries/party.queries'
*
* const queryClient = useQueryClient()
*
* // Populate cache with server data
* setQueryData(queryClient, partyKeys.detail(shortcode), serverParty)
* ```
*
* @param queryClient - The QueryClient instance
* @param queryKey - The query key to set data for
* @param data - The data to set in the cache
*/
export function setQueryData<TData>(
queryClient: QueryClient,
queryKey: readonly unknown[],
data: TData
): void {
queryClient.setQueryData(queryKey, data)
}

View file

@ -11,14 +11,12 @@
import { authStore } from '$lib/stores/auth.store'
import { browser } from '$app/environment'
import { QueryClientProvider } from '@tanstack/svelte-query'
import { createQueryClient } from '$lib/query/queryClient'
const queryClient = createQueryClient()
import type { LayoutData } from './$types'
// Get `data` and `children` from the router via $props()
// Use a more flexible type that allows additional properties from child pages
// QueryClient is now created in +layout.ts for SSR support
const { data, children } = $props<{
data: any // Allow any data to pass through from child pages
data: LayoutData & { [key: string]: any } // Allow any data to pass through from child pages
children: () => any
}>()
@ -110,7 +108,7 @@
<link rel="icon" href={favicon} />
</svelte:head>
<QueryClientProvider client={queryClient}>
<QueryClientProvider client={data.queryClient}>
<Tooltip.Provider>
<div class="app-container" class:sidebar-open={sidebar.isOpen}>
<div class="main-pane">

34
src/routes/+layout.ts Normal file
View file

@ -0,0 +1,34 @@
/**
* Root Layout Load Function
*
* Creates a QueryClient instance for SSR support with TanStack Query v6.
* The QueryClient is created here so it can be used for prefetching in
* child page load functions.
*
* @module routes/+layout
*/
import type { LayoutLoad } from './$types'
import { browser } from '$app/environment'
import { QueryClient } from '@tanstack/svelte-query'
export const load: LayoutLoad = async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Disable queries on server - they will be prefetched explicitly
enabled: browser,
// Cache data for 5 minutes before considering it stale
staleTime: 1000 * 60 * 5,
// Keep unused data in cache for 30 minutes
gcTime: 1000 * 60 * 30,
// Retry failed requests twice
retry: 2,
// Don't refetch on window focus by default
refetchOnWindowFocus: false
}
}
})
return { queryClient }
}

View file

@ -1,12 +1,39 @@
<script lang="ts">
import type { PageData } from './$types'
import Party from '$lib/components/party/Party.svelte'
import { createQuery } from '@tanstack/svelte-query'
import { partyQueries } from '$lib/api/queries/party.queries'
import { withInitialData } from '$lib/query/ssr'
let { data }: { data: PageData } = $props()
/**
* TanStack Query v6 SSR Integration Example
*
* This demonstrates the `withInitialData` pattern for pages using +page.server.ts.
* The server-fetched party data is used as initial data for the query, which means:
*
* 1. No loading state on initial render (data is already available)
* 2. The query can refetch in the background when data becomes stale
* 3. The data is cached and shared across components using the same query key
*
* Note: The Party component currently manages its own state, so we pass the
* server data directly. In a future refactor, the Party component could use
* this query directly for automatic cache updates and background refetching.
*/
const partyQuery = createQuery(() => ({
...partyQueries.byShortcode(data.party?.shortcode ?? ''),
...withInitialData(data.party),
enabled: !!data.party?.shortcode
}))
// Use query data if available, fall back to server data
// This allows the query to refetch and update the UI automatically
const party = $derived(partyQuery.data ?? data.party)
</script>
{#if data?.party}
<Party party={data.party} canEdit={data.canEdit || false} authUserId={data.authUserId} />
{#if party}
<Party party={party} canEdit={data.canEdit || false} authUserId={data.authUserId} />
{:else}
<div>
<h1>Party not found</h1>