From 149f30c5385c53bdfec879072430902ab70771c3 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Sat, 29 Nov 2025 03:29:28 -0800 Subject: [PATCH] Make parties more DRY (#444) We extracted utility functions from the Party.svelte component in order to make things more DRY. --- src/lib/components/party/Party.svelte | 409 +++++--------------------- src/lib/utils/errors.ts | 64 ++++ src/lib/utils/gridHelpers.ts | 99 +++++++ src/lib/utils/gridOperations.ts | 207 +++++++++++++ src/lib/utils/gridStateUpdater.ts | 137 +++++++++ src/lib/utils/jobSkills.ts | 66 +++++ 6 files changed, 649 insertions(+), 333 deletions(-) create mode 100644 src/lib/utils/errors.ts create mode 100644 src/lib/utils/gridHelpers.ts create mode 100644 src/lib/utils/gridOperations.ts create mode 100644 src/lib/utils/gridStateUpdater.ts create mode 100644 src/lib/utils/jobSkills.ts diff --git a/src/lib/components/party/Party.svelte b/src/lib/components/party/Party.svelte index 1998be11..cfd46f26 100644 --- a/src/lib/components/party/Party.svelte +++ b/src/lib/components/party/Party.svelte @@ -23,6 +23,11 @@ import { Gender } from '$lib/utils/jobUtils' import { openJobSelectionSidebar, openJobSkillSelectionSidebar } from '$lib/features/job/openJobSidebar.svelte' import { partyAdapter } from '$lib/api/adapters/party.adapter' + import { extractErrorMessage } from '$lib/utils/errors' + import { transformSkillsToArray } from '$lib/utils/jobSkills' + import { findNextEmptySlot, SLOT_NOT_FOUND } from '$lib/utils/gridHelpers' + import { executeGridOperation, removeGridItem, updateGridItem } from '$lib/utils/gridOperations' + import { updateGridItemUncap } from '$lib/utils/gridStateUpdater' interface Props { party?: Party @@ -122,40 +127,14 @@ throw new Error('Cannot swap items in unsaved party') } - // Both source and target should have items for swap - if (!source.itemId || !target.itemId) { - throw new Error('Invalid swap operation - missing items') - } - - // Call appropriate grid service method based on type - if (source.type === 'weapon') { - await gridService.moveWeapon(party.id, source.itemId, target.position, editKey || undefined, { - shortcode: party.shortcode - }) - } else if (source.type === 'character') { - await gridService.moveCharacter( - party.id, - source.itemId, - target.position, - editKey || undefined, - { - shortcode: party.shortcode - } - ) - } else if (source.type === 'summon') { - await gridService.moveSummon(party.id, source.itemId, target.position, editKey || undefined, { - shortcode: party.shortcode - }) - } else { - throw new Error(`Unknown item type: ${source.type}`) - } - - // Clear cache and refresh party data - partyService.clearPartyCache(party.shortcode) - const updated = await partyService.getByShortcode(party.shortcode) - return updated - - throw new Error(`Unknown item type: ${source.type}`) + return executeGridOperation( + 'swap', + source, + target, + { partyId: party.id, shortcode: party.shortcode, editKey }, + gridService, + partyService + ) } async function handleMove(source: any, target: any): Promise { @@ -163,36 +142,14 @@ throw new Error('Cannot move items in unsaved party') } - // Source should have an item, target should be empty - if (!source.itemId || target.itemId) { - throw new Error('Invalid move operation') - } - - // Call appropriate grid service method based on type - if (source.type === 'character') { - await gridService.moveCharacter( - party.id, - source.itemId, - target.position, - editKey || undefined, - { shortcode: party.shortcode } - ) - } else if (source.type === 'weapon') { - await gridService.moveWeapon(party.id, source.itemId, target.position, editKey || undefined, { - shortcode: party.shortcode - }) - } else if (source.type === 'summon') { - await gridService.moveSummon(party.id, source.itemId, target.position, editKey || undefined, { - shortcode: party.shortcode - }) - } else { - throw new Error(`Unknown item type: ${source.type}`) - } - - // Clear cache and refresh party data - partyService.clearPartyCache(party.shortcode) - const updated = await partyService.getByShortcode(party.shortcode) - return updated + return executeGridOperation( + 'move', + source, + target, + { partyId: party.id, shortcode: party.shortcode, editKey }, + gridService, + partyService + ) } // Localized name helper: accepts either an object with { name: { en, ja } } @@ -405,12 +362,7 @@ console.log('[Party] New skill:', skill) // Convert skills object to array format expected by API - const skillsArray = Object.entries(updatedSkills) - .filter(([_, skill]) => skill !== null && skill !== undefined) - .map(([slotKey, skill]) => ({ - id: skill!.id, - slot: parseInt(slotKey) - })) + const skillsArray = transformSkillsToArray(updatedSkills) console.log('[Party] Skills array to send:', skillsArray) @@ -420,33 +372,7 @@ ) party = updated } catch (e: any) { - // Extract detailed error message from nested structure - let errorDetails = e?.details - - // Navigate through nested details structure - while (errorDetails?.details) { - errorDetails = errorDetails.details - } - - if (errorDetails?.errors) { - if (errorDetails.errors.message) { - // Simple message format - error = errorDetails.errors.message - } else { - // Field-based errors - const errorMessages = Object.entries(errorDetails.errors) - .map(([field, messages]) => { - if (Array.isArray(messages)) { - return messages.join(', ') - } - return String(messages) - }) - .join('; ') - error = errorMessages || e.message || 'Failed to update skill' - } - } else { - error = e?.message || 'Failed to update skill' - } + error = extractErrorMessage(e, 'Failed to update skill') console.error('Failed to update skill:', e) } finally { loading = false @@ -466,12 +392,7 @@ console.log('[Party] Updated jobSkills after removal:', updatedSkills) // Convert skills object to array format expected by API - const skillsArray = Object.entries(updatedSkills) - .filter(([_, skill]) => skill !== null && skill !== undefined) - .map(([slotKey, skill]) => ({ - id: skill!.id, - slot: parseInt(slotKey) - })) + const skillsArray = transformSkillsToArray(updatedSkills) console.log('[Party] Skills array to send after removal:', skillsArray) @@ -481,33 +402,7 @@ ) party = updated } catch (e: any) { - // Extract detailed error message from nested structure - let errorDetails = e?.details - - // Navigate through nested details structure - while (errorDetails?.details) { - errorDetails = errorDetails.details - } - - if (errorDetails?.errors) { - if (errorDetails.errors.message) { - // Simple message format - error = errorDetails.errors.message - } else { - // Field-based errors - const errorMessages = Object.entries(errorDetails.errors) - .map(([field, messages]) => { - if (Array.isArray(messages)) { - return messages.join(', ') - } - return String(messages) - }) - .join('; ') - error = errorMessages || e.message || 'Failed to remove skill' - } - } else { - error = e?.message || 'Failed to remove skill' - } + error = extractErrorMessage(e, 'Failed to remove skill') console.error('Failed to remove skill:', e) } finally { loading = false @@ -529,12 +424,7 @@ delete updatedSkills[String(slot) as keyof typeof updatedSkills] // Convert skills object to array format expected by API - const skillsArray = Object.entries(updatedSkills) - .filter(([_, skill]) => skill !== null && skill !== undefined) - .map(([slotKey, skill]) => ({ - id: skill!.id, - slot: parseInt(slotKey) - })) + const skillsArray = transformSkillsToArray(updatedSkills) const updated = await partyAdapter.updateJobSkills( party.shortcode, @@ -590,50 +480,8 @@ party = updated // Find next empty slot for continuous adding - let nextEmptySlot = -999 // sentinel value meaning no empty slot found - - if (activeTab === GridType.Weapon) { - // Check mainhand first (position -1) - if (!party.weapons.find((w) => w.position === -1 || w.mainhand)) { - nextEmptySlot = -1 - } else { - // Check grid slots 0-8 - for (let i = 0; i < 9; i++) { - if (!party.weapons.find((w) => w.position === i)) { - nextEmptySlot = i - break - } - } - } - } else if (activeTab === GridType.Summon) { - // Check main summon first (position -1) - if (!party.summons.find((s) => s.position === -1 || s.main)) { - nextEmptySlot = -1 - } else { - // Check grid slots 0-5 - for (let i = 0; i < 6; i++) { - if (!party.summons.find((s) => s.position === i)) { - nextEmptySlot = i - break - } - } - // Check friend summon (position 6) - if (nextEmptySlot === -999 && !party.summons.find((s) => s.position === 6 || s.friend)) { - nextEmptySlot = 6 - } - } - } else if (activeTab === GridType.Character) { - // Check character slots 0-4 - for (let i = 0; i < 5; i++) { - if (!party.characters.find((c) => c.position === i)) { - nextEmptySlot = i - break - } - } - } - - // If there's another empty slot, update selectedSlot to it - if (nextEmptySlot !== -999) { + const nextEmptySlot = findNextEmptySlot(party, activeTab) + if (nextEmptySlot !== SLOT_NOT_FOUND) { selectedSlot = nextEmptySlot } // Note: Sidebar stays open for continuous adding @@ -660,17 +508,15 @@ const clientGridService = { async removeWeapon(partyId: string, gridWeaponId: string, _editKey?: string) { try { - // Remove returns null, so we need to update local state - await gridService.removeWeapon(partyId, gridWeaponId, editKey || undefined, { - shortcode: party.shortcode - }) - - // Update local state by removing the weapon - const updatedParty = { ...party } - if (updatedParty.weapons) { - updatedParty.weapons = updatedParty.weapons.filter((w: any) => w.id !== gridWeaponId) - } - return updatedParty + return await removeGridItem( + 'weapon', + partyId, + gridWeaponId, + party, + party.shortcode, + editKey, + gridService + ) } catch (err) { console.error('Failed to remove weapon:', err) throw err @@ -678,17 +524,15 @@ }, async removeSummon(partyId: string, gridSummonId: string, _editKey?: string) { try { - // Remove returns null, so we need to update local state - await gridService.removeSummon(partyId, gridSummonId, editKey || undefined, { - shortcode: party.shortcode - }) - - // Update local state by removing the summon - const updatedParty = { ...party } - if (updatedParty.summons) { - updatedParty.summons = updatedParty.summons.filter((s: any) => s.id !== gridSummonId) - } - return updatedParty + return await removeGridItem( + 'summon', + partyId, + gridSummonId, + party, + party.shortcode, + editKey, + gridService + ) } catch (err) { console.error('Failed to remove summon:', err) throw err @@ -696,19 +540,15 @@ }, async removeCharacter(partyId: string, gridCharacterId: string, _editKey?: string) { try { - // Remove returns null, so we need to update local state - await gridService.removeCharacter(partyId, gridCharacterId, editKey || undefined, { - shortcode: party.shortcode - }) - - // Update local state by removing the character - const updatedParty = { ...party } - if (updatedParty.characters) { - updatedParty.characters = updatedParty.characters.filter( - (c: any) => c.id !== gridCharacterId - ) - } - return updatedParty + return await removeGridItem( + 'character', + partyId, + gridCharacterId, + party, + party.shortcode, + editKey, + gridService + ) } catch (err) { console.error('Failed to remove character:', err) throw err @@ -716,14 +556,7 @@ }, async updateWeapon(partyId: string, gridWeaponId: string, updates: any, _editKey?: string) { try { - // Use the grid service to update weapon - const updated = await gridService.updateWeapon( - partyId, - gridWeaponId, - updates, - editKey || undefined - ) - return updated + return await updateGridItem('weapon', partyId, gridWeaponId, updates, editKey, gridService) } catch (err) { console.error('Failed to update weapon:', err) throw err @@ -731,14 +564,7 @@ }, async updateSummon(partyId: string, gridSummonId: string, updates: any, _editKey?: string) { try { - // Use the grid service to update summon - const updated = await gridService.updateSummon( - partyId, - gridSummonId, - updates, - editKey || undefined - ) - return updated + return await updateGridItem('summon', partyId, gridSummonId, updates, editKey, gridService) } catch (err) { console.error('Failed to update summon:', err) throw err @@ -751,14 +577,7 @@ _editKey?: string ) { try { - // Use the grid service to update character - const updated = await gridService.updateCharacter( - partyId, - gridCharacterId, - updates, - editKey || undefined - ) - return updated + return await updateGridItem('character', partyId, gridCharacterId, updates, editKey, gridService) } catch (err) { console.error('Failed to update character:', err) throw err @@ -771,40 +590,14 @@ _editKey?: string ) { try { - const response = await gridService.updateCharacterUncap( + return await updateGridItemUncap( + 'character', + { gridItemId: gridCharacterId, uncapLevel, transcendenceStep }, party.id, - gridCharacterId, - uncapLevel, - transcendenceStep, - editKey || undefined + party, + editKey, + gridService ) - // The API returns {gridCharacter: {...}} with the updated item only (transformed to camelCase) - // We need to update just that character in the current party state - if (response.gridCharacter || response.grid_character) { - const updatedChar = response.gridCharacter || response.grid_character - const updatedParty = { ...party } - if (updatedParty.characters) { - const charIndex = updatedParty.characters.findIndex( - (c: any) => c.id === gridCharacterId - ) - if (charIndex !== -1) { - // Preserve the character object reference but update uncap fields - const existingChar = updatedParty.characters[charIndex] - if (existingChar) { - updatedParty.characters[charIndex] = { - ...existingChar, - id: existingChar.id, - position: existingChar.position, - character: existingChar.character, - uncapLevel: updatedChar.uncapLevel ?? updatedChar.uncap_level, - transcendenceStep: updatedChar.transcendenceStep ?? updatedChar.transcendence_step - } - } - return updatedParty - } - } - } - return party // Return unchanged party if update failed } catch (err) { console.error('Failed to update character uncap:', err) throw err @@ -817,39 +610,14 @@ _editKey?: string ) { try { - const response = await gridService.updateWeaponUncap( + return await updateGridItemUncap( + 'weapon', + { gridItemId: gridWeaponId, uncapLevel, transcendenceStep }, party.id, - gridWeaponId, - uncapLevel, - transcendenceStep, - editKey || undefined + party, + editKey, + gridService ) - // The API returns {gridWeapon: {...}} with the updated item only (transformed to camelCase) - // We need to update just that weapon in the current party state - if (response.gridWeapon || response.grid_weapon) { - const updatedWeapon = response.gridWeapon || response.grid_weapon - const updatedParty = { ...party } - if (updatedParty.weapons) { - const weaponIndex = updatedParty.weapons.findIndex((w: any) => w.id === gridWeaponId) - if (weaponIndex !== -1) { - // Preserve the weapon object reference but update uncap fields - const existingWeapon = updatedParty.weapons[weaponIndex] - if (existingWeapon) { - updatedParty.weapons[weaponIndex] = { - ...existingWeapon, - id: existingWeapon.id, - position: existingWeapon.position, - weapon: existingWeapon.weapon, - uncapLevel: updatedWeapon.uncapLevel ?? updatedWeapon.uncap_level, - transcendenceStep: - updatedWeapon.transcendenceStep ?? updatedWeapon.transcendence_step - } - } - return updatedParty - } - } - } - return party // Return unchanged party if update failed } catch (err) { console.error('Failed to update weapon uncap:', err) throw err @@ -862,39 +630,14 @@ _editKey?: string ) { try { - const response = await gridService.updateSummonUncap( + return await updateGridItemUncap( + 'summon', + { gridItemId: gridSummonId, uncapLevel, transcendenceStep }, party.id, - gridSummonId, - uncapLevel, - transcendenceStep, - editKey || undefined + party, + editKey, + gridService ) - // The API returns {gridSummon: {...}} with the updated item only (transformed to camelCase) - // We need to update just that summon in the current party state - if (response.gridSummon || response.grid_summon) { - const updatedSummon = response.gridSummon || response.grid_summon - const updatedParty = { ...party } - if (updatedParty.summons) { - const summonIndex = updatedParty.summons.findIndex((s: any) => s.id === gridSummonId) - if (summonIndex !== -1) { - // Preserve the summon object reference but update uncap fields - const existingSummon = updatedParty.summons[summonIndex] - if (existingSummon) { - updatedParty.summons[summonIndex] = { - ...existingSummon, - id: existingSummon.id, - position: existingSummon.position, - summon: existingSummon.summon, - uncapLevel: updatedSummon.uncapLevel ?? updatedSummon.uncap_level, - transcendenceStep: - updatedSummon.transcendenceStep ?? updatedSummon.transcendence_step - } - } - return updatedParty - } - } - } - return party // Return unchanged party if update failed } catch (err) { console.error('Failed to update summon uncap:', err) throw err diff --git a/src/lib/utils/errors.ts b/src/lib/utils/errors.ts new file mode 100644 index 00000000..e557f6ff --- /dev/null +++ b/src/lib/utils/errors.ts @@ -0,0 +1,64 @@ +/** + * Error message extraction utilities + * Handles complex nested error structures from API responses + */ + +export interface NestedErrorDetails { + details?: NestedErrorDetails + errors?: { message?: string; [key: string]: any } + message?: string +} + +/** + * Extracts user-friendly error message from nested API error structures + * Handles the pattern: error.details.details.errors.message + * + * @param error - The error object to extract from + * @param fallbackMessage - Message to return if extraction fails + * @returns Extracted error message or fallback + * + * @example + * ```typescript + * try { + * await api.updateParty(...) + * } catch (e) { + * error = extractErrorMessage(e, 'Failed to update party') + * } + * ``` + */ +export function extractErrorMessage( + error: any, + fallbackMessage: string = 'An error occurred' +): string { + if (!error) return fallbackMessage + + // Navigate through nested details structure + let errorDetails: NestedErrorDetails | undefined = error?.details + while (errorDetails?.details) { + errorDetails = errorDetails.details + } + + // Try to extract message from various formats + if (errorDetails?.errors) { + // Simple message format + if (errorDetails.errors.message) { + return errorDetails.errors.message + } + + // Field-based errors - combine all messages + const errorMessages = Object.entries(errorDetails.errors) + .map(([field, messages]) => { + if (Array.isArray(messages)) { + return messages.join(', ') + } + return String(messages) + }) + .filter((msg) => msg && msg !== 'undefined') + .join('; ') + + if (errorMessages) return errorMessages + } + + // Fallback to error.message + return error?.message || fallbackMessage +} diff --git a/src/lib/utils/gridHelpers.ts b/src/lib/utils/gridHelpers.ts new file mode 100644 index 00000000..570e2bf9 --- /dev/null +++ b/src/lib/utils/gridHelpers.ts @@ -0,0 +1,99 @@ +/** + * Grid slot finding and helper utilities + * Handles finding available positions in weapon, summon, and character grids + */ + +import type { Party } from '$lib/types/api/party' +import { GridType } from '$lib/types/enums' + +/** Sentinel value indicating no empty slot was found */ +export const SLOT_NOT_FOUND = -999 + +export interface SlotRange { + start: number + end: number + specialSlots?: number[] // e.g., mainhand (-1), friend summon (6) +} + +/** Grid slot configuration for each grid type */ +const GRID_CONFIGS: Record = { + [GridType.Weapon]: { start: 0, end: 8, specialSlots: [-1] }, // mainhand + 9 grid slots + [GridType.Summon]: { start: 0, end: 5, specialSlots: [-1, 6] }, // main + 6 grid + friend + [GridType.Character]: { start: 0, end: 4, specialSlots: [] } // 5 slots (0-4) +} + +/** + * Finds the next empty slot in a grid + * + * @param party - Current party state + * @param gridType - Type of grid to search (weapon, summon, or character) + * @returns Position number of next empty slot, or SLOT_NOT_FOUND if grid is full + * + * @example + * ```typescript + * const nextSlot = findNextEmptySlot(party, GridType.Weapon) + * if (nextSlot !== SLOT_NOT_FOUND) { + * selectedSlot = nextSlot + * } + * ``` + */ +export function findNextEmptySlot(party: Party, gridType: GridType): number { + const config = GRID_CONFIGS[gridType] + const collection = getCollectionForType(party, gridType) + + // Check special slots first (e.g., mainhand, main summon) + for (const specialSlot of config.specialSlots || []) { + if (!isSlotOccupied(collection, specialSlot, gridType)) { + return specialSlot + } + } + + // Check regular grid slots + for (let i = config.start; i <= config.end; i++) { + if (!isSlotOccupied(collection, i, gridType)) { + return i + } + } + + return SLOT_NOT_FOUND +} + +/** + * Gets the appropriate collection array for a grid type + */ +function getCollectionForType(party: Party, gridType: GridType) { + switch (gridType) { + case GridType.Weapon: + return party.weapons + case GridType.Summon: + return party.summons + case GridType.Character: + return party.characters + } +} + +/** + * Checks if a specific slot position is occupied + * Handles special cases for mainhand weapons, main/friend summons + */ +function isSlotOccupied(collection: any[], position: number, gridType: GridType): boolean { + // For weapons, check both position and mainhand flag + if (gridType === GridType.Weapon) { + return collection.some( + (item) => item.position === position || (position === -1 && item.mainhand) + ) + } + + // For summons, check position, main, and friend flags + if (gridType === GridType.Summon) { + return collection.some( + (item) => + item.position === position || + (position === -1 && item.main) || + (position === 6 && item.friend) + ) + } + + // For characters, simple position check + return collection.some((item) => item.position === position) +} diff --git a/src/lib/utils/gridOperations.ts b/src/lib/utils/gridOperations.ts new file mode 100644 index 00000000..8946e4c9 --- /dev/null +++ b/src/lib/utils/gridOperations.ts @@ -0,0 +1,207 @@ +/** + * Grid operation utilities + * Consolidates duplicated grid CRUD logic + */ + +import type { Party } from '$lib/types/api/party' +import type { GridService } from '$lib/services/grid.service' +import type { PartyService } from '$lib/services/party.service' + +export type GridItemType = 'character' | 'weapon' | 'summon' +export type GridCollection = 'characters' | 'weapons' | 'summons' + +/** + * Maps grid item type to collection key in Party object + * + * @param type - Grid item type (character, weapon, or summon) + * @returns Collection key name + * + * @example + * ```typescript + * const key = getCollectionKey('weapon') // Returns: 'weapons' + * const items = party[key] // Access party.weapons + * ``` + */ +export function getCollectionKey(type: GridItemType): GridCollection { + const map: Record = { + character: 'characters', + weapon: 'weapons', + summon: 'summons' + } + return map[type] +} + +/** + * Maps operation and grid type to service method name + * + * @param operation - CRUD operation type + * @param type - Grid item type + * @returns Method name on GridService + * + * @example + * ```typescript + * const methodName = getGridMethodName('add', 'weapon') // Returns: 'addWeapon' + * const methodName = getGridMethodName('remove', 'character') // Returns: 'removeCharacter' + * ``` + */ +export function getGridMethodName( + operation: 'add' | 'move' | 'remove' | 'update', + type: GridItemType +): string { + const typeCapitalized = type.charAt(0).toUpperCase() + type.slice(1) + return `${operation}${typeCapitalized}` +} + +/** + * Execute grid move/swap operation + * Consolidates handleSwap and handleMove logic + * + * @param operationType - Type of operation (move or swap) + * @param source - Source item information + * @param target - Target position information + * @param context - Party context (ID, shortcode, edit key) + * @param gridService - Grid service instance + * @param partyService - Party service instance + * @returns Updated party data + * + * @example + * ```typescript + * const updated = await executeGridOperation( + * 'swap', + * { type: 'weapon', itemId: 'abc123', position: 0 }, + * { type: 'weapon', position: 1, itemId: 'def456' }, + * { partyId: party.id, shortcode: party.shortcode, editKey }, + * gridService, + * partyService + * ) + * ``` + */ +export async function executeGridOperation( + operationType: 'move' | 'swap', + source: { type: GridItemType; itemId: string; position: number }, + target: { type: GridItemType; position: number; itemId?: string }, + context: { partyId: string; shortcode: string; editKey?: string }, + gridService: GridService, + partyService: PartyService +): Promise { + // Validation + if (operationType === 'swap' && !target.itemId) { + throw new Error('Swap operation requires target item') + } + if (operationType === 'move' && target.itemId) { + throw new Error('Move operation requires empty target') + } + + // Call appropriate grid service method + const methodName = getGridMethodName('move', source.type) + const method = (gridService as any)[methodName] + + if (!method) { + throw new Error(`Unknown grid method: ${methodName}`) + } + + await method.call( + gridService, + context.partyId, + source.itemId, + target.position, + context.editKey, + { shortcode: context.shortcode } + ) + + // Clear cache and refresh party + partyService.clearPartyCache(context.shortcode) + return await partyService.getByShortcode(context.shortcode) +} + +/** + * Generic grid item remover + * Replaces three similar remove{Type} methods in clientGridService + * + * @param type - Grid item type to remove + * @param partyId - Party UUID + * @param gridItemId - Grid item UUID to remove + * @param party - Current party state + * @param shortcode - Party shortcode for cache clearing + * @param editKey - Optional edit key for authorization + * @param gridService - Grid service instance + * @returns Updated party with item removed + * + * @example + * ```typescript + * const updated = await removeGridItem( + * 'weapon', + * party.id, + * gridWeaponId, + * party, + * party.shortcode, + * editKey, + * gridService + * ) + * ``` + */ +export async function removeGridItem( + type: GridItemType, + partyId: string, + gridItemId: string, + party: Party, + shortcode: string, + editKey: string | undefined, + gridService: GridService +): Promise { + // Call appropriate remove method + const methodName = getGridMethodName('remove', type) + const method = (gridService as any)[methodName] + + await method.call(gridService, partyId, gridItemId, editKey, { shortcode }) + + // Update local state by removing item + const collection = getCollectionKey(type) + const updatedParty = { ...party } + + if (updatedParty[collection]) { + updatedParty[collection] = updatedParty[collection].filter( + (item: any) => item.id !== gridItemId + ) + } + + return updatedParty +} + +/** + * Generic grid item updater + * Replaces three similar update{Type} methods + * + * @param type - Grid item type to update + * @param partyId - Party UUID + * @param gridItemId - Grid item UUID to update + * @param updates - Object containing fields to update + * @param editKey - Optional edit key for authorization + * @param gridService - Grid service instance + * @returns Updated grid item data + * + * @example + * ```typescript + * const updated = await updateGridItem( + * 'weapon', + * party.id, + * gridWeaponId, + * { ax1: 10, ax2: 5 }, + * editKey, + * gridService + * ) + * ``` + */ +export async function updateGridItem( + type: GridItemType, + partyId: string, + gridItemId: string, + updates: any, + editKey: string | undefined, + gridService: GridService +): Promise { + const methodName = getGridMethodName('update', type) + const method = (gridService as any)[methodName] + + return await method.call(gridService, partyId, gridItemId, updates, editKey) +} diff --git a/src/lib/utils/gridStateUpdater.ts b/src/lib/utils/gridStateUpdater.ts new file mode 100644 index 00000000..6ceee096 --- /dev/null +++ b/src/lib/utils/gridStateUpdater.ts @@ -0,0 +1,137 @@ +/** + * Grid state update utilities + * Handles optimistic updates for uncap levels and other grid item properties + */ + +import type { Party } from '$lib/types/api/party' +import type { GridService } from '$lib/services/grid.service' +import type { GridItemType, GridCollection } from './gridOperations' +import { getCollectionKey } from './gridOperations' + +export interface UncapUpdateParams { + gridItemId: string + uncapLevel?: number + transcendenceStep?: number +} + +/** + * Generic function to update uncap levels for any grid item type + * Replaces updateCharacterUncap, updateWeaponUncap, updateSummonUncap + * + * @param itemType - Type of grid item (character, weapon, or summon) + * @param params - Uncap update parameters + * @param partyId - Party UUID + * @param currentParty - Current party state + * @param editKey - Optional edit key for authorization + * @param gridService - Grid service instance + * @returns Updated party with modified uncap levels + * + * @example + * ```typescript + * const updated = await updateGridItemUncap( + * 'weapon', + * { gridItemId: 'abc123', uncapLevel: 4, transcendenceStep: 1 }, + * party.id, + * party, + * editKey, + * gridService + * ) + * ``` + */ +export async function updateGridItemUncap( + itemType: GridItemType, + params: UncapUpdateParams, + partyId: string, + currentParty: Party, + editKey: string | undefined, + gridService: GridService +): Promise { + // Get configuration for this item type + const config = getGridItemConfig(itemType) + + // Call appropriate service method + const response = await config.updateMethod( + gridService, + partyId, + params.gridItemId, + params.uncapLevel, + params.transcendenceStep, + editKey + ) + + // Extract updated item from response (handle both camelCase and snake_case) + const updatedItem = response[config.responseKey] || response[config.snakeCaseKey] + if (!updatedItem) return currentParty + + // Update party state optimistically + return mergeUpdatedGridItem(currentParty, config.collectionKey, params.gridItemId, { + uncapLevel: updatedItem.uncapLevel ?? updatedItem.uncap_level, + transcendenceStep: updatedItem.transcendenceStep ?? updatedItem.transcendence_step + }) +} + +/** + * Configuration map for grid item types + */ +function getGridItemConfig(itemType: GridItemType) { + const configs = { + character: { + updateMethod: (gs: GridService, ...args: any[]) => gs.updateCharacterUncap(...args), + responseKey: 'gridCharacter', + snakeCaseKey: 'grid_character', + collectionKey: 'characters' as GridCollection + }, + weapon: { + updateMethod: (gs: GridService, ...args: any[]) => gs.updateWeaponUncap(...args), + responseKey: 'gridWeapon', + snakeCaseKey: 'grid_weapon', + collectionKey: 'weapons' as GridCollection + }, + summon: { + updateMethod: (gs: GridService, ...args: any[]) => gs.updateSummonUncap(...args), + responseKey: 'gridSummon', + snakeCaseKey: 'grid_summon', + collectionKey: 'summons' as GridCollection + } + } + + return configs[itemType] +} + +/** + * Merges updates into a grid item within party state + * Preserves immutability by creating new objects + * + * @param party - Current party state + * @param collection - Collection key (characters, weapons, or summons) + * @param itemId - Grid item ID to update + * @param updates - Fields to update + * @returns New party object with updates applied + */ +function mergeUpdatedGridItem( + party: Party, + collection: GridCollection, + itemId: string, + updates: any +): Party { + const updatedParty = { ...party } + const items = updatedParty[collection] + + if (!items) return party + + const itemIndex = items.findIndex((item: any) => item.id === itemId) + if (itemIndex === -1) return party + + const existingItem = items[itemIndex] + if (!existingItem) return party + + // Merge updates while preserving essential properties + items[itemIndex] = { + ...existingItem, + ...updates, + id: existingItem.id, + position: existingItem.position + } + + return updatedParty +} diff --git a/src/lib/utils/jobSkills.ts b/src/lib/utils/jobSkills.ts new file mode 100644 index 00000000..9dc92431 --- /dev/null +++ b/src/lib/utils/jobSkills.ts @@ -0,0 +1,66 @@ +/** + * Job skills transformation utilities + * Handles converting between object and array formats for job skills + */ + +import type { JobSkill } from '$lib/types/api/entities' + +export interface JobSkillsMap { + [slot: string]: JobSkill | null | undefined +} + +export interface JobSkillPayload { + id: string + slot: number +} + +/** + * Converts job skills object to API array format + * Filters out null/undefined values and adds slot numbers + * + * @param skillsMap - Object mapping slot numbers to job skills + * @returns Array of job skill payloads ready for API submission + * + * @example + * ```typescript + * const skillsMap = { '0': skill1, '1': skill2, '2': null } + * const payload = transformSkillsToArray(skillsMap) + * // Returns: [{ id: 'skill1-id', slot: 0 }, { id: 'skill2-id', slot: 1 }] + * ``` + */ +export function transformSkillsToArray(skillsMap: JobSkillsMap): JobSkillPayload[] { + return Object.entries(skillsMap) + .filter(([_, skill]) => skill !== null && skill !== undefined) + .map(([slotKey, skill]) => ({ + id: skill!.id, + slot: parseInt(slotKey) + })) +} + +/** + * Updates a skill in a specific slot (returns new object, immutable) + * + * @param currentSkills - Current job skills map + * @param slot - Slot number to update + * @param skill - Job skill to set, or null to remove + * @returns New skills map with the update applied + * + * @example + * ```typescript + * const updated = updateSkillInSlot(currentSkills, 0, newSkill) + * const removed = updateSkillInSlot(currentSkills, 1, null) + * ``` + */ +export function updateSkillInSlot( + currentSkills: JobSkillsMap, + slot: number, + skill: JobSkill | null +): JobSkillsMap { + const updated = { ...currentSkills } + if (skill === null) { + delete updated[String(slot)] + } else { + updated[String(slot)] = skill + } + return updated +}