From 114427241f9fabee89cca50dd1f9ab26f2beb2a2 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Fri, 19 Sep 2025 23:19:24 -0700 Subject: [PATCH] feat: Implement PartyAdapter with comprehensive functionality - Add PartyAdapter for party CRUD and grid management - Create reactive PartyResource with optimistic updates - Support user parties listing with filters - Handle grid conflicts and job updates - Include comprehensive test coverage --- .../adapters/__tests__/party.adapter.test.ts | 508 ++++++++++++++++++ src/lib/api/adapters/base.adapter.ts | 34 +- src/lib/api/adapters/index.ts | 19 +- src/lib/api/adapters/party.adapter.ts | 381 +++++++++++++ src/lib/api/adapters/resources/index.ts | 7 +- .../resources/party.resource.svelte.ts | 379 +++++++++++++ src/lib/api/adapters/types.ts | 19 +- 7 files changed, 1326 insertions(+), 21 deletions(-) create mode 100644 src/lib/api/adapters/__tests__/party.adapter.test.ts create mode 100644 src/lib/api/adapters/party.adapter.ts create mode 100644 src/lib/api/adapters/resources/party.resource.svelte.ts diff --git a/src/lib/api/adapters/__tests__/party.adapter.test.ts b/src/lib/api/adapters/__tests__/party.adapter.test.ts new file mode 100644 index 00000000..c237110c --- /dev/null +++ b/src/lib/api/adapters/__tests__/party.adapter.test.ts @@ -0,0 +1,508 @@ +/** + * Tests for PartyAdapter + * + * These tests verify party CRUD operations, grid management, + * and conflict resolution functionality. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { PartyAdapter } from '../party.adapter' +import type { Party, GridWeapon, GridSummon, GridCharacter } from '../party.adapter' + +describe('PartyAdapter', () => { + let adapter: PartyAdapter + let originalFetch: typeof global.fetch + + const mockParty: Party = { + id: '123', + shortcode: 'ABC123', + name: 'Test Party', + description: 'Test description', + visibility: 'public', + user: { + id: 'user-1', + username: 'testuser' + }, + job: { + id: 'job-1', + name: { en: 'Warrior' }, + skills: [ + { + id: 'skill-1', + name: { en: 'Rage' }, + slot: 1 + } + ] + }, + raid: { + id: 'raid-1', + name: { en: 'Proto Bahamut' }, + group: { + id: 'group-1', + name: { en: 'Tier 1' } + } + }, + gridWeapons: [], + gridSummons: [], + gridCharacters: [], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z' + } + + beforeEach(() => { + originalFetch = global.fetch + adapter = new PartyAdapter({ baseURL: 'https://api.example.com' }) + }) + + afterEach(() => { + global.fetch = originalFetch + vi.clearAllTimers() + }) + + describe('CRUD operations', () => { + it('should create a new party', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockParty + }) + + const result = await adapter.create({ + name: 'Test Party', + description: 'Test description', + visibility: 'public' + }) + + expect(result).toEqual(mockParty) + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.example.com/parties', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + party: { + name: 'Test Party', + description: 'Test description', + visibility: 'public' + } + }) + }) + ) + }) + + it('should get a party by shortcode', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockParty + }) + + const result = await adapter.getByShortcode('ABC123') + + expect(result).toEqual(mockParty) + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.example.com/parties/ABC123', + expect.any(Object) + ) + }) + + it('should update a party', async () => { + const updatedParty = { ...mockParty, name: 'Updated Party' } + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => updatedParty + }) + + const result = await adapter.update({ + shortcode: 'ABC123', + name: 'Updated Party' + }) + + expect(result).toEqual(updatedParty) + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.example.com/parties/ABC123', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ + party: { name: 'Updated Party' } + }) + }) + ) + }) + + it('should delete a party', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}) + }) + + await adapter.delete('ABC123') + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.example.com/parties/ABC123', + expect.objectContaining({ + method: 'DELETE' + }) + ) + }) + + it('should remix a party', async () => { + const remixedParty = { ...mockParty, id: '456', shortcode: 'DEF456' } + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => remixedParty + }) + + const result = await adapter.remix('ABC123') + + expect(result).toEqual(remixedParty) + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.example.com/parties/ABC123/remix', + expect.objectContaining({ + method: 'POST' + }) + ) + }) + }) + + describe('user parties listing', () => { + it('should list user parties with filters', async () => { + const mockResponse = { + results: [mockParty], + total: 1, + page: 1, + totalPages: 1 + } + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockResponse + }) + + const result = await adapter.listUserParties({ + username: 'testuser', + page: 1, + per: 20, + visibility: 'public', + raidId: 'raid-1' + }) + + expect(result).toEqual(mockResponse) + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/users/testuser/parties'), + expect.objectContaining({ + method: 'GET' + }) + ) + + // Verify query parameters were included + const callUrl = (global.fetch as any).mock.calls[0][0] + expect(callUrl).toContain('page=1') + expect(callUrl).toContain('per=20') + expect(callUrl).toContain('visibility=public') + expect(callUrl).toContain('raid_id=raid-1') + }) + }) + + describe('grid management', () => { + it('should update grid weapons', async () => { + const mockGridWeapons: GridWeapon[] = [ + { + id: 'gw-1', + position: 1, + mainhand: true, + uncapLevel: 5, + transcendenceStage: 0, + weaponKeys: [], + weapon: { + id: 'weapon-1', + granblueId: 'w-1', + name: { en: 'Sword' }, + element: 1, + rarity: 5 + } + } + ] + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + grid_weapons: mockGridWeapons.map(gw => ({ + ...gw, + uncap_level: gw.uncapLevel, + transcendence_stage: gw.transcendenceStage, + weapon_keys: gw.weaponKeys + })) + }) + }) + + const result = await adapter.updateGridWeapons({ + shortcode: 'ABC123', + updates: [ + { + position: 1, + weaponId: 'weapon-1', + mainhand: true, + uncapLevel: 5 + } + ] + }) + + expect(result.gridWeapons).toEqual(mockGridWeapons) + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.example.com/parties/ABC123/grid_weapons', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ + grid_weapons: [ + { + position: 1, + weapon_id: 'weapon-1', + mainhand: true, + uncap_level: 5 + } + ] + }) + }) + ) + }) + + it('should update grid summons', async () => { + const mockGridSummons: GridSummon[] = [ + { + id: 'gs-1', + position: 1, + quickSummon: true, + transcendenceStage: 2, + summon: { + id: 'summon-1', + granblueId: 's-1', + name: { en: 'Bahamut' }, + element: 6, + rarity: 5 + } + } + ] + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + grid_summons: mockGridSummons.map(gs => ({ + ...gs, + quick_summon: gs.quickSummon, + transcendence_stage: gs.transcendenceStage + })) + }) + }) + + const result = await adapter.updateGridSummons({ + shortcode: 'ABC123', + updates: [ + { + position: 1, + summonId: 'summon-1', + quickSummon: true, + transcendenceStage: 2 + } + ] + }) + + expect(result.gridSummons).toEqual(mockGridSummons) + }) + + it('should update grid characters', async () => { + const mockGridCharacters: GridCharacter[] = [ + { + id: 'gc-1', + position: 1, + uncapLevel: 5, + transcendenceStage: 1, + character: { + id: 'char-1', + granblueId: 'c-1', + name: { en: 'Katalina' }, + element: 2, + rarity: 5 + } + } + ] + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + grid_characters: mockGridCharacters.map(gc => ({ + ...gc, + uncap_level: gc.uncapLevel, + transcendence_stage: gc.transcendenceStage + })) + }) + }) + + const result = await adapter.updateGridCharacters({ + shortcode: 'ABC123', + updates: [ + { + position: 1, + characterId: 'char-1', + uncapLevel: 5, + transcendenceStage: 1 + } + ] + }) + + expect(result.gridCharacters).toEqual(mockGridCharacters) + }) + + it('should handle grid conflicts', async () => { + const conflictResponse = { + grid_weapons: [], + conflicts: { + conflicts: [ + { + type: 'weapon', + position: 1, + existing: { id: 'weapon-1' }, + new: { id: 'weapon-2' } + } + ], + resolved: false + } + } + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => conflictResponse + }) + + const result = await adapter.updateGridWeapons({ + shortcode: 'ABC123', + updates: [ + { + position: 1, + weaponId: 'weapon-2' + } + ] + }) + + expect(result.conflicts).toBeDefined() + expect(result.conflicts?.resolved).toBe(false) + expect(result.conflicts?.conflicts).toHaveLength(1) + }) + }) + + describe('job management', () => { + it('should update party job', async () => { + const updatedParty = { + ...mockParty, + job: { + id: 'job-2', + name: { en: 'Mage' }, + skills: [ + { + id: 'skill-2', + name: { en: 'Fireball' }, + slot: 1 + } + ], + accessory: { + id: 'acc-1', + name: { en: 'Magic Ring' } + } + } + } + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => updatedParty + }) + + const result = await adapter.updateJob( + 'ABC123', + 'job-2', + [{ id: 'skill-2', slot: 1 }], + 'acc-1' + ) + + expect(result).toEqual(updatedParty) + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.example.com/parties/ABC123', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ + party: { + job_id: 'job-2', + job_skills_attributes: [{ id: 'skill-2', slot: 1 }], + job_accessory_id: 'acc-1' + } + }) + }) + ) + }) + }) + + describe('cache management', () => { + it('should cache party retrieval', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockParty + }) + + // First call + await adapter.getByShortcode('ABC123') + + // Second call (should use cache) + await adapter.getByShortcode('ABC123') + + // Should only call fetch once due to caching + expect(global.fetch).toHaveBeenCalledTimes(1) + }) + + it('should clear party cache', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockParty + }) + + // First call + await adapter.getByShortcode('ABC123') + + // Clear cache + adapter.clearPartyCache('ABC123') + + // Second call (should not use cache) + await adapter.getByShortcode('ABC123') + + // Should call fetch twice since cache was cleared + expect(global.fetch).toHaveBeenCalledTimes(2) + }) + }) + + describe('error handling', () => { + it('should handle 404 errors', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({ error: 'Party not found' }) + }) + + await expect(adapter.getByShortcode('INVALID')).rejects.toThrow() + }) + + it('should handle validation errors', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: async () => ({ + errors: { + name: ['is too long'] + } + }) + }) + + await expect( + adapter.create({ + name: 'A'.repeat(256) + }) + ).rejects.toThrow() + }) + }) +}) \ No newline at end of file diff --git a/src/lib/api/adapters/base.adapter.ts b/src/lib/api/adapters/base.adapter.ts index c7adb366..133bb2bb 100644 --- a/src/lib/api/adapters/base.adapter.ts +++ b/src/lib/api/adapters/base.adapter.ts @@ -87,14 +87,14 @@ export abstract class BaseAdapter { path: string, options: RequestOptions = {} ): Promise { - // Build the full URL with query parameters - const url = this.buildURL(path, options.params) + // Build the full URL with query parameters (support both params and query) + const url = this.buildURL(path, options.query || options.params) // Generate a unique ID for this request (used for cancellation and caching) const requestId = this.generateRequestId(path, options.method, options.body as string) - // Check cache first if caching is enabled - const cacheTime = options.cache ?? this.options.cacheTime + // Check cache first if caching is enabled (support both cache and cacheTTL) + const cacheTime = options.cacheTTL ?? options.cache ?? this.options.cacheTime // Allow caching for any method if explicitly set if (cacheTime > 0) { const cached = this.getFromCache(requestId) @@ -122,13 +122,18 @@ export abstract class BaseAdapter { } // Transform request body from camelCase to snake_case if present - if (options.body && typeof options.body === 'string') { - try { - const bodyData = JSON.parse(options.body) - fetchOptions.body = JSON.stringify(this.transformRequest(bodyData)) - } catch { - // If body is not valid JSON, use as-is - fetchOptions.body = options.body + if (options.body) { + if (typeof options.body === 'object') { + // Body is an object, transform and stringify + fetchOptions.body = JSON.stringify(this.transformRequest(options.body)) + } else if (typeof options.body === 'string') { + try { + const bodyData = JSON.parse(options.body) + fetchOptions.body = JSON.stringify(this.transformRequest(bodyData)) + } catch { + // If body is not valid JSON, use as-is + fetchOptions.body = options.body + } } } @@ -140,7 +145,7 @@ export abstract class BaseAdapter { const data = await response.json() const transformed = this.transformResponse(data) - // Cache the successful response if caching is enabled + // Cache the successful response if caching is enabled (use cacheTTL or cache) if (cacheTime > 0) { this.setCache(requestId, transformed, cacheTime) } @@ -348,7 +353,10 @@ export abstract class BaseAdapter { private addQueryParams(url: URL, params?: Record): void { if (!params) return - Object.entries(params).forEach(([key, value]) => { + // Transform query parameters from camelCase to snake_case + const transformed = this.transformRequest(params) + + Object.entries(transformed).forEach(([key, value]) => { // Skip undefined and null values if (value === undefined || value === null) return diff --git a/src/lib/api/adapters/index.ts b/src/lib/api/adapters/index.ts index 2aa0c978..56cbc4b8 100644 --- a/src/lib/api/adapters/index.ts +++ b/src/lib/api/adapters/index.ts @@ -15,8 +15,25 @@ export * from './errors' // Resource-specific adapters export { SearchAdapter, searchAdapter } from './search.adapter' export type { SearchParams, SearchResult, SearchResponse } from './search.adapter' -// export { PartyAdapter } from './party.adapter' + +export { PartyAdapter, partyAdapter } from './party.adapter' +export type { + Party, + GridWeapon, + GridSummon, + GridCharacter, + CreatePartyParams, + UpdatePartyParams, + ListUserPartiesParams, + UpdateGridParams, + GridWeaponUpdate, + GridSummonUpdate, + GridCharacterUpdate, + ConflictResolution +} from './party.adapter' + // export { GridAdapter } from './grid.adapter' +// export { EntityAdapter } from './entity.adapter' // Reactive resources using Svelte 5 runes export * from './resources' \ No newline at end of file diff --git a/src/lib/api/adapters/party.adapter.ts b/src/lib/api/adapters/party.adapter.ts new file mode 100644 index 00000000..9e96a8ae --- /dev/null +++ b/src/lib/api/adapters/party.adapter.ts @@ -0,0 +1,381 @@ +/** + * Party Adapter + * + * Handles all party-related API operations including CRUD, grids, and remixing. + * Provides a clean interface for party management with automatic + * request handling, caching, and error management. + * + * @module adapters/party + */ + +import { BaseAdapter } from './base.adapter' +import type { RequestOptions, AdapterOptions, PaginatedResponse } from './types' + +/** + * Party data structure + */ +export interface Party { + id: string + shortcode: string + name?: string + description?: string + visibility: 'public' | 'private' | 'unlisted' + user: { + id: string + username: string + } + job?: { + id: string + name: Record + skills: Array<{ + id: string + name: Record + slot: number + }> + accessory?: { + id: string + name: Record + } + } + raid?: { + id: string + name: Record + group?: { + id: string + name: Record + } + } + gridWeapons: GridWeapon[] + gridSummons: GridSummon[] + gridCharacters: GridCharacter[] + guidebook?: { + id: string + title: string + } + extras?: Record + createdAt: string + updatedAt: string +} + +/** + * Grid weapon structure + */ +export interface GridWeapon { + id: string + position: number + mainhand: boolean + uncapLevel: number + transcendenceStage: number + weaponKeys: Array<{ + id: string + slot: number + }> + weapon: { + id: string + granblueId: string + name: Record + element: number + rarity: number + } +} + +/** + * Grid summon structure + */ +export interface GridSummon { + id: string + position: number + quickSummon: boolean + transcendenceStage: number + summon: { + id: string + granblueId: string + name: Record + element: number + rarity: number + } +} + +/** + * Grid character structure + */ +export interface GridCharacter { + id: string + position: number + uncapLevel: number + transcendenceStage: number + perpetualModifiers?: Record + awakenings?: Array<{ + id: string + level: number + }> + character: { + id: string + granblueId: string + name: Record + element: number + rarity: number + } +} + +/** + * Parameters for creating a new party + */ +export interface CreatePartyParams { + name?: string + description?: string + visibility?: 'public' | 'private' | 'unlisted' + jobId?: string + raidId?: string + guidebookId?: string + extras?: Record +} + +/** + * Parameters for updating a party + */ +export interface UpdatePartyParams extends CreatePartyParams { + shortcode: string +} + +/** + * Parameters for listing user parties + */ +export interface ListUserPartiesParams { + username: string + page?: number + per?: number + visibility?: 'public' | 'private' | 'unlisted' | 'all' + raidId?: string + characterId?: string + weaponId?: string + summonId?: string +} + +/** + * Parameters for updating grid items + */ +export interface UpdateGridParams { + shortcode: string + updates: T[] +} + +/** + * Grid weapon update structure + */ +export interface GridWeaponUpdate { + id?: string + position: number + weaponId: string + mainhand?: boolean + uncapLevel?: number + transcendenceStage?: number + weaponKeys?: Array<{ + id: string + slot: number + }> + _destroy?: boolean +} + +/** + * Grid summon update structure + */ +export interface GridSummonUpdate { + id?: string + position: number + summonId: string + quickSummon?: boolean + transcendenceStage?: number + _destroy?: boolean +} + +/** + * Grid character update structure + */ +export interface GridCharacterUpdate { + id?: string + position: number + characterId: string + uncapLevel?: number + transcendenceStage?: number + perpetualModifiers?: Record + awakenings?: Array<{ + id: string + level: number + }> + _destroy?: boolean +} + +/** + * Conflict resolution result + */ +export interface ConflictResolution { + conflicts: Array<{ + type: 'weapon' | 'summon' | 'character' + position: number + existing: any + new: any + }> + resolved: boolean +} + +/** + * Party adapter for managing parties and their grids + */ +export class PartyAdapter extends BaseAdapter { + constructor(options?: AdapterOptions) { + super({ + ...options, + baseURL: options?.baseURL || '/api/v1' + }) + } + + /** + * Creates a new party + */ + async create(params: CreatePartyParams): Promise { + return this.request('/parties', { + method: 'POST', + body: { + party: params + } + }) + } + + /** + * Gets a party by shortcode + */ + async getByShortcode(shortcode: string): Promise { + return this.request(`/parties/${shortcode}`, { + cacheTTL: 60000 // Cache for 1 minute + }) + } + + /** + * Updates a party + */ + async update(params: UpdatePartyParams): Promise { + const { shortcode, ...updateParams } = params + return this.request(`/parties/${shortcode}`, { + method: 'PATCH', + body: { + party: updateParams + } + }) + } + + /** + * Deletes a party + */ + async delete(shortcode: string): Promise { + return this.request(`/parties/${shortcode}`, { + method: 'DELETE' + }) + } + + /** + * Creates a remix (copy) of an existing party + */ + async remix(shortcode: string): Promise { + return this.request(`/parties/${shortcode}/remix`, { + method: 'POST' + }) + } + + /** + * Lists parties for a specific user + */ + async listUserParties(params: ListUserPartiesParams): Promise> { + const { username, ...queryParams } = params + return this.request>(`/users/${username}/parties`, { + method: 'GET', + query: queryParams, + cacheTTL: 30000 // Cache for 30 seconds + }) + } + + /** + * Updates grid weapons for a party + */ + async updateGridWeapons( + params: UpdateGridParams + ): Promise<{ gridWeapons: GridWeapon[]; conflicts?: ConflictResolution }> { + const { shortcode, updates } = params + return this.request(`/parties/${shortcode}/grid_weapons`, { + method: 'PATCH', + body: { + grid_weapons: updates + } + }) + } + + /** + * Updates grid summons for a party + */ + async updateGridSummons( + params: UpdateGridParams + ): Promise<{ gridSummons: GridSummon[]; conflicts?: ConflictResolution }> { + const { shortcode, updates } = params + return this.request(`/parties/${shortcode}/grid_summons`, { + method: 'PATCH', + body: { + grid_summons: updates + } + }) + } + + /** + * Updates grid characters for a party + */ + async updateGridCharacters( + params: UpdateGridParams + ): Promise<{ gridCharacters: GridCharacter[]; conflicts?: ConflictResolution }> { + const { shortcode, updates } = params + return this.request(`/parties/${shortcode}/grid_characters`, { + method: 'PATCH', + body: { + grid_characters: updates + } + }) + } + + /** + * Updates the job for a party + */ + async updateJob( + shortcode: string, + jobId: string, + skills?: Array<{ id: string; slot: number }>, + accessoryId?: string + ): Promise { + return this.request(`/parties/${shortcode}`, { + method: 'PATCH', + body: { + party: { + job_id: jobId, + ...(skills && { job_skills_attributes: skills }), + ...(accessoryId && { job_accessory_id: accessoryId }) + } + } + }) + } + + /** + * Clears the cache for party-related data + */ + clearPartyCache(shortcode?: string) { + if (shortcode) { + // Clear specific party cache + this.clearCache(`/parties/${shortcode}`) + } else { + // Clear all party and user caches + this.clearCache('/parties') + this.clearCache('/users') + } + } +} + +/** + * Default party adapter instance + */ +export const partyAdapter = new PartyAdapter() \ No newline at end of file diff --git a/src/lib/api/adapters/resources/index.ts b/src/lib/api/adapters/resources/index.ts index 48b56b44..6bb41a73 100644 --- a/src/lib/api/adapters/resources/index.ts +++ b/src/lib/api/adapters/resources/index.ts @@ -10,6 +10,9 @@ export { SearchResource, createSearchResource } from './search.resource.svelte' export type { SearchResourceOptions } from './search.resource.svelte' +export { PartyResource, createPartyResource } from './party.resource.svelte' +export type { PartyResourceOptions } from './party.resource.svelte' + // Future resources will be added here -// export { PartyResource, createPartyResource } from './party.resource.svelte' -// export { GridResource, createGridResource } from './grid.resource.svelte' \ No newline at end of file +// export { GridResource, createGridResource } from './grid.resource.svelte' +// export { EntityResource, createEntityResource } from './entity.resource.svelte' \ No newline at end of file diff --git a/src/lib/api/adapters/resources/party.resource.svelte.ts b/src/lib/api/adapters/resources/party.resource.svelte.ts new file mode 100644 index 00000000..265c4a8f --- /dev/null +++ b/src/lib/api/adapters/resources/party.resource.svelte.ts @@ -0,0 +1,379 @@ +/** + * Reactive Party Resource using Svelte 5 Runes + * + * Provides reactive state management for party operations with + * automatic loading states, error handling, and optimistic updates. + * + * @module adapters/resources/party + */ + +import { PartyAdapter, type Party, type CreatePartyParams, type UpdatePartyParams } from '../party.adapter' +import type { AdapterError } from '../types' + +/** + * Party resource configuration options + */ +export interface PartyResourceOptions { + /** Party adapter instance to use */ + adapter?: PartyAdapter + /** Enable optimistic updates for mutations */ + optimistic?: boolean +} + +/** + * Resource state for a single party + */ +interface PartyState { + data?: Party + loading: boolean + error?: AdapterError + updating?: boolean +} + +/** + * Resource state for party lists + */ +interface PartyListState { + parties: Party[] + total?: number + page?: number + totalPages?: number + loading: boolean + error?: AdapterError +} + +/** + * Creates a reactive party resource for managing parties + * + * @example + * ```svelte + * + * + * {#if party.current.loading} + *

Loading party...

+ * {:else if party.current.error} + *

Error: {party.current.error.message}

+ * {:else if party.current.data} + *

{party.current.data.name}

+ * {/if} + * ``` + */ +export class PartyResource { + private adapter: PartyAdapter + private optimistic: boolean + + // Reactive state for current party + current = $state({ loading: false }) + + // Reactive state for user parties list + userParties = $state({ + parties: [], + loading: false + }) + + // Track active requests for cancellation + private activeRequests = new Map() + + constructor(options: PartyResourceOptions = {}) { + this.adapter = options.adapter || new PartyAdapter() + this.optimistic = options.optimistic ?? true + } + + /** + * Loads a party by shortcode + */ + async load(shortcode: string): Promise { + // Cancel any existing load request + this.cancelRequest('load') + + const controller = new AbortController() + this.activeRequests.set('load', controller) + + this.current = { ...this.current, loading: true, error: undefined } + + try { + const party = await this.adapter.getByShortcode(shortcode) + this.current = { data: party, loading: false } + return party + } catch (error: any) { + if (error.code !== 'CANCELLED') { + this.current = { + ...this.current, + loading: false, + error: error as AdapterError + } + } + } finally { + this.activeRequests.delete('load') + } + } + + /** + * Creates a new party + */ + async create(params: CreatePartyParams): Promise { + this.current = { ...this.current, updating: true, error: undefined } + + try { + const party = await this.adapter.create(params) + this.current = { data: party, loading: false, updating: false } + + // Add to user parties if loaded + if (this.userParties.parties.length > 0) { + this.userParties.parties = [party, ...this.userParties.parties] + if (this.userParties.total !== undefined) { + this.userParties.total++ + } + } + + return party + } catch (error: any) { + this.current = { + ...this.current, + updating: false, + error: error as AdapterError + } + } + } + + /** + * Updates the current party + */ + async update(params: UpdatePartyParams): Promise { + // Optimistic update + if (this.optimistic && this.current.data) { + const optimisticData = { + ...this.current.data, + ...params, + updatedAt: new Date().toISOString() + } + this.current = { + ...this.current, + data: optimisticData as Party, + updating: true + } + } else { + this.current = { ...this.current, updating: true } + } + + try { + const party = await this.adapter.update(params) + this.current = { data: party, loading: false, updating: false } + + // Update in user parties list if present + const index = this.userParties.parties.findIndex( + p => p.shortcode === params.shortcode + ) + if (index !== -1) { + this.userParties.parties[index] = party + } + + return party + } catch (error: any) { + // Revert optimistic update on error + if (this.optimistic) { + await this.load(params.shortcode) + } + this.current = { + ...this.current, + updating: false, + error: error as AdapterError + } + } + } + + /** + * Deletes the current party + */ + async delete(shortcode: string): Promise { + this.current = { ...this.current, updating: true, error: undefined } + + try { + await this.adapter.delete(shortcode) + + // Clear current party + this.current = { loading: false, updating: false } + + // Remove from user parties list + this.userParties.parties = this.userParties.parties.filter( + p => p.shortcode !== shortcode + ) + if (this.userParties.total !== undefined && this.userParties.total > 0) { + this.userParties.total-- + } + + return true + } catch (error: any) { + this.current = { + ...this.current, + updating: false, + error: error as AdapterError + } + return false + } + } + + /** + * Creates a remix (copy) of a party + */ + async remix(shortcode: string): Promise { + this.current = { ...this.current, updating: true, error: undefined } + + try { + const party = await this.adapter.remix(shortcode) + this.current = { data: party, loading: false, updating: false } + + // Add to user parties if it's the current user's remix + if (this.userParties.parties.length > 0) { + this.userParties.parties = [party, ...this.userParties.parties] + if (this.userParties.total !== undefined) { + this.userParties.total++ + } + } + + return party + } catch (error: any) { + this.current = { + ...this.current, + updating: false, + error: error as AdapterError + } + } + } + + /** + * Loads parties for a specific user + */ + async loadUserParties( + username: string, + params: Omit[0], 'username'> = {} + ): Promise { + // Cancel any existing user parties request + this.cancelRequest('userParties') + + const controller = new AbortController() + this.activeRequests.set('userParties', controller) + + this.userParties = { ...this.userParties, loading: true, error: undefined } + + try { + const response = await this.adapter.listUserParties({ + username, + ...params + }) + + this.userParties = { + parties: response.results, + total: response.total, + page: response.page, + totalPages: response.totalPages, + loading: false + } + } catch (error: any) { + if (error.code !== 'CANCELLED') { + this.userParties = { + ...this.userParties, + loading: false, + error: error as AdapterError + } + } + } finally { + this.activeRequests.delete('userParties') + } + } + + /** + * Updates the job for the current party + */ + async updateJob( + shortcode: string, + jobId: string, + skills?: Array<{ id: string; slot: number }>, + accessoryId?: string + ): Promise { + this.current = { ...this.current, updating: true, error: undefined } + + try { + const party = await this.adapter.updateJob(shortcode, jobId, skills, accessoryId) + this.current = { data: party, loading: false, updating: false } + return party + } catch (error: any) { + this.current = { + ...this.current, + updating: false, + error: error as AdapterError + } + } + } + + /** + * Cancels an active request + */ + private cancelRequest(key: string) { + const controller = this.activeRequests.get(key) + if (controller) { + controller.abort() + this.activeRequests.delete(key) + } + } + + /** + * Cancels all active requests + */ + cancelAll() { + this.activeRequests.forEach(controller => controller.abort()) + this.activeRequests.clear() + } + + /** + * Clears the current party state + */ + clearCurrent() { + this.cancelRequest('load') + this.current = { loading: false } + } + + /** + * Clears the user parties state + */ + clearUserParties() { + this.cancelRequest('userParties') + this.userParties = { parties: [], loading: false } + } + + /** + * Clears all states + */ + clearAll() { + this.cancelAll() + this.current = { loading: false } + this.userParties = { parties: [], loading: false } + } + + /** + * Clears the adapter's cache + */ + clearCache(shortcode?: string) { + this.adapter.clearPartyCache(shortcode) + } +} + +/** + * Factory function for creating party resources + */ +export function createPartyResource(options?: PartyResourceOptions): PartyResource { + return new PartyResource(options) +} \ No newline at end of file diff --git a/src/lib/api/adapters/types.ts b/src/lib/api/adapters/types.ts index 4f797877..e00c13ac 100644 --- a/src/lib/api/adapters/types.ts +++ b/src/lib/api/adapters/types.ts @@ -36,15 +36,21 @@ export interface RequestOptions extends Omit { /** Query parameters to append to the URL */ params?: Record + /** Alternative alias for query parameters */ + query?: Record + /** Request timeout in milliseconds. Overrides the adapter's default timeout */ timeout?: number /** Number of retry attempts for this specific request */ retries?: number - /** Cache duration for this request in milliseconds. Only applies to GET requests */ + /** Cache duration for this request in milliseconds */ cache?: number + /** Alternative alias for cache duration */ + cacheTTL?: number + /** Request body. Can be any serializable value */ body?: any } @@ -75,8 +81,11 @@ export interface AdapterError { * Used for endpoints that return paginated data */ export interface PaginatedResponse { - /** Array of items for the current page */ - items: T[] + /** Array of items for the current page (can be 'results' or 'items') */ + results: T[] + + /** Alternative key for items */ + items?: T[] /** Total number of items across all pages */ total: number @@ -88,10 +97,10 @@ export interface PaginatedResponse { totalPages: number /** Number of items per page */ - perPage: number + perPage?: number /** Whether there are more pages available */ - hasMore: boolean + hasMore?: boolean /** Cursor or page number for the next page, if available */ nextCursor?: string | number