diff --git a/src/lib/api/adapters/resources/party.resource.svelte.ts b/src/lib/api/adapters/resources/party.resource.svelte.ts index f1141f4e..cf4af750 100644 --- a/src/lib/api/adapters/resources/party.resource.svelte.ts +++ b/src/lib/api/adapters/resources/party.resource.svelte.ts @@ -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 */ diff --git a/src/lib/api/adapters/resources/search.resource.svelte.ts b/src/lib/api/adapters/resources/search.resource.svelte.ts index 68c8d1a1..180d31e4 100644 --- a/src/lib/api/adapters/resources/search.resource.svelte.ts +++ b/src/lib/api/adapters/resources/search.resource.svelte.ts @@ -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) -} \ No newline at end of file +} diff --git a/src/lib/api/mutations/grid.mutations.ts b/src/lib/api/mutations/grid.mutations.ts new file mode 100644 index 00000000..edb38f78 --- /dev/null +++ b/src/lib/api/mutations/grid.mutations.ts @@ -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 + * + * ``` + */ +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 + * + * ``` + */ +export function useUpdateGridWeapon() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ id, updates }: { id: string; partyShortcode: string; updates: Partial }) => + gridAdapter.updateWeapon(id, updates), + onMutate: async ({ id, partyShortcode, updates }) => { + await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) }) + + const previousParty = queryClient.getQueryData(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(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(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 }) => + gridAdapter.updateCharacter(id, updates), + onMutate: async ({ id, partyShortcode, updates }) => { + await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) }) + + const previousParty = queryClient.getQueryData(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(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(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 }) => + gridAdapter.updateSummon(id, updates), + onMutate: async ({ id, partyShortcode, updates }) => { + await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) }) + + const previousParty = queryClient.getQueryData(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(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(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(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) }) + } + })) +} diff --git a/src/lib/api/mutations/job.mutations.ts b/src/lib/api/mutations/job.mutations.ts new file mode 100644 index 00000000..1b6cd7db --- /dev/null +++ b/src/lib/api/mutations/job.mutations.ts @@ -0,0 +1,182 @@ +/** + * 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 + * + * ``` + */ +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(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 + * + * ``` + */ +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 + * + * ``` + */ +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(partyKeys.detail(shortcode)) + + // Optimistically remove the skill from the slot + if (previousParty?.jobSkills) { + const updatedSkills = { ...previousParty.jobSkills } + delete updatedSkills[slot] + 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 + * + * ``` + */ +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) }) + } + })) +} diff --git a/src/lib/api/mutations/party.mutations.ts b/src/lib/api/mutations/party.mutations.ts new file mode 100644 index 00000000..0a035273 --- /dev/null +++ b/src/lib/api/mutations/party.mutations.ts @@ -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 + * + * ``` + */ +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 + * + * ``` + */ +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(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 + * + * ``` + */ +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 + * + * ``` + */ +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 + * + * ``` + */ +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(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 + * + * ``` + */ +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(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 + * + * ``` + */ +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) }) + } + })) +} diff --git a/src/lib/api/queries/job.queries.ts b/src/lib/api/queries/job.queries.ts new file mode 100644 index 00000000..fb438513 --- /dev/null +++ b/src/lib/api/queries/job.queries.ts @@ -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 + ) => + infiniteQueryOptions({ + queryKey: ['jobs', jobId, 'skills', 'search', params] as const, + queryFn: async ({ pageParam }): Promise => { + 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) => + [...jobKeys.skills(jobId), 'search', params] as const, + accessories: (jobId: string) => [...jobKeys.all, jobId, 'accessories'] as const, + allSkills: () => [...jobKeys.all, 'skills', 'all'] as const +} diff --git a/src/lib/api/queries/party.queries.ts b/src/lib/api/queries/party.queries.ts new file mode 100644 index 00000000..b8ae67a2 --- /dev/null +++ b/src/lib/api/queries/party.queries.ts @@ -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) => + infiniteQueryOptions({ + queryKey: ['parties', 'list', params] as const, + queryFn: async ({ pageParam }): Promise => { + 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 + ) => + infiniteQueryOptions({ + queryKey: ['parties', 'user', username, params] as const, + queryFn: async ({ pageParam }): Promise => { + 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) => + [...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 +} diff --git a/src/lib/api/queries/user.queries.ts b/src/lib/api/queries/user.queries.ts new file mode 100644 index 00000000..3130ec8d --- /dev/null +++ b/src/lib/api/queries/user.queries.ts @@ -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 => { + 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 => { + 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 +}