Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/__tests__/sorting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import type { SelectedPotential, PotentialPriority } from '../types'

const PRIORITY_ORDER: Record<PotentialPriority, number> = {
Core: 0,
Medium: 1,
Optional: 2,
}

function sortPotentials(potentials: SelectedPotential[]): SelectedPotential[] {
return [...potentials].sort((a, b) => {
if (a.priority !== b.priority) {
return PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]
}
return a.selectionTimestamp - b.selectionTimestamp
})
}

describe('Potential Sorting Logic', () => {
it('should sort by priority (Core > Medium > Optional)', () => {
const potentials: any[] = [
{ id: 1, priority: 'Optional', selectionTimestamp: 100 },
{ id: 2, priority: 'Medium', selectionTimestamp: 200 },
{ id: 3, priority: 'Core', selectionTimestamp: 300 },
]
const sorted = sortPotentials(potentials)
expect(sorted[0].id).toBe(3) // Core
expect(sorted[1].id).toBe(2) // Medium
expect(sorted[2].id).toBe(1) // Optional
})

it('should sort by selection order within the same priority', () => {
const potentials: any[] = [
{ id: 1, priority: 'Medium', selectionTimestamp: 500 },
{ id: 2, priority: 'Medium', selectionTimestamp: 100 },
{ id: 3, priority: 'Medium', selectionTimestamp: 300 },
]
const sorted = sortPotentials(potentials)
expect(sorted[0].id).toBe(2) // Earliest
expect(sorted[1].id).toBe(3) // Middle
expect(sorted[2].id).toBe(1) // Latest
})

it('should correctly handle mixed priorities and timestamps', () => {
const potentials: any[] = [
{ id: 1, priority: 'Optional', selectionTimestamp: 10 },
{ id: 2, priority: 'Core', selectionTimestamp: 100 },
{ id: 3, priority: 'Medium', selectionTimestamp: 5 },
{ id: 4, priority: 'Medium', selectionTimestamp: 50 },
]
const sorted = sortPotentials(potentials)
expect(sorted[0].id).toBe(2) // Core
expect(sorted[1].id).toBe(3) // Medium (earlier)
expect(sorted[2].id).toBe(4) // Medium (later)
expect(sorted[3].id).toBe(1) // Optional
})
})
97 changes: 97 additions & 0 deletions src/components/single-selected.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { InfoIcon, X } from 'lucide-react'
import { Slider } from './ui/slider'
import { Button } from './ui/button'
import {
HybridTooltip,
HybridTooltipContent,
HybridTooltipTrigger,
} from './ui/hybrid-tooltip'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/select'
import type { PotentialPriority, Slot } from '@/types'
import { useTrekkerStore } from '@/lib/trekker-store'
import ResponsivePotential from './responsive-potential'

export function SingleSelected({ slot, id }: { slot: Slot; id: number }) {
const s = useTrekkerStore((state) => state.potentials[slot][id])
const updateLevel = useTrekkerStore((sel) => sel.updateLevel)
const removePotential = useTrekkerStore((sel) => sel.removePotential)
const updatePriority = useTrekkerStore((sel) => sel.updatePriority)

if (!s) return null

return (
<div className="flex flex-col gap-2 justify-center">
<HybridTooltip>
<HybridTooltipTrigger asChild>
<div className="relative">
<ResponsivePotential
key={'selected' + s.imgId + s.id}
rarity={s.rarity}
imgId={s.imgId}
name={s.name}
subIcon={s.subIcon}
/>
{s.rarity !== 0 && (
<div className="absolute -top-px left-3 text-xs font-semibold text-indigo-500">
{s.level}
</div>
)}

<Button
variant="destructive"
size="icon"
className="absolute -top-1 -right-1 rounded-full size-4 border border-white"
onClick={() => removePotential(slot, s.id)}
>
<X className="size-3" />
</Button>
</div>
</HybridTooltipTrigger>
<HybridTooltipContent>
<p>{s.briefDesc}</p>
</HybridTooltipContent>
</HybridTooltip>

<div className="w-20 space-y-2">
<Slider
defaultValue={[s.level || 1]}
step={1}
disabled={s.rarity === 0}
min={1}
max={6}
onValueChange={(newValue: Array<number>) =>
updateLevel(slot, s.id, newValue[0])
}
></Slider>
<Select
disabled={s.rarity === 0}
value={s.priority}
onValueChange={(value) =>
updatePriority(slot, id, value as PotentialPriority)
}
>
<SelectTrigger className="text-[10px] w-full px-2" size="sm">
<SelectValue placeholder="PotentialPriority" />
</SelectTrigger>
<SelectContent align="start">
<SelectItem className="text-[10px]" value="Core">
Core
</SelectItem>
<SelectItem className="text-[10px]" value="Medium">
Medium
</SelectItem>
<SelectItem className="text-[10px]" value="Optional">
Optional
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}
110 changes: 19 additions & 91 deletions src/components/ss-potentials.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { memo } from 'react'
import { InfoIcon, PlusIcon, X } from 'lucide-react'
import { InfoIcon, PlusIcon } from 'lucide-react'
import { useShallow } from 'zustand/shallow'
import ResponsivePotential from './responsive-potential'
import { Slider } from './ui/slider'
import { Button } from './ui/button'
import { ScrollArea, ScrollBar } from './ui/scroll-area'
import {
Expand All @@ -11,108 +10,37 @@ import {
HybridTooltipProvider,
HybridTooltipTrigger,
} from './ui/hybrid-tooltip'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/select'
import type { PotentialPriority, Slot } from '@/types'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { useTrekkerStore } from '@/lib/trekker-store'
import { SingleSelected } from './single-selected'

const PRIORITY_ORDER: Record<PotentialPriority, number> = {
Core: 0,
Medium: 1,
Optional: 2,
}

type SSPotentialsProps = {
slot: Slot
type: 'main' | 'support'
}

function SingleSelected({ slot, id }: { slot: Slot; id: number }) {
const s = useTrekkerStore((state) => state.potentials[slot][id])
const updateLevel = useTrekkerStore((sel) => sel.updateLevel)
const removePotential = useTrekkerStore((sel) => sel.removePotential)
const updatePriority = useTrekkerStore((sel) => sel.updatePriority)
return (
<div className="flex flex-col gap-2 justify-center">
<HybridTooltip>
<HybridTooltipTrigger asChild>
<div className="relative">
<ResponsivePotential
key={'selected' + s.imgId + s.id}
rarity={s.rarity}
imgId={s.imgId}
name={s.name}
subIcon={s.subIcon}
/>
{s.rarity !== 0 && (
<div className="absolute -top-px left-3 text-xs font-semibold text-indigo-500">
{s.level}
</div>
)}

<Button
variant="destructive"
size="icon"
className="absolute -top-1 -right-1 rounded-full size-4 border border-white"
onClick={() => removePotential(slot, s.id)}
>
<X className="size-3" />
</Button>
</div>
</HybridTooltipTrigger>
<HybridTooltipContent>
<p>{s.briefDesc}</p>
</HybridTooltipContent>
</HybridTooltip>

<div className="w-20 space-y-2">
<Slider
defaultValue={[1]}
step={1}
disabled={s.rarity === 0}
min={1}
max={6}
onValueChange={(newValue: Array<number>) =>
updateLevel(slot, s.id, newValue[0])
}
></Slider>
<Select
disabled={s.rarity === 0}
value={s.priority}
onValueChange={(value) =>
updatePriority(slot, id, value as PotentialPriority)
}
>
<SelectTrigger className="text-[10px] w-full px-2" size="sm">
<SelectValue placeholder="PotentialPriority" />
</SelectTrigger>
<SelectContent align="start">
<SelectItem className="text-[10px]" value="Core">
Core
</SelectItem>
<SelectItem className="text-[10px]" value="Medium">
Medium
</SelectItem>
<SelectItem className="text-[10px]" value="Optional">
Optional
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}

function SSPotentials({ slot, type }: SSPotentialsProps) {
const potentials = useTrekkerStore((s) => s.trekkers[slot]?.potential)
const selected = useTrekkerStore(
const selectedIds = useTrekkerStore(
useShallow((s) =>
Object.values(s.potentials[slot])
.sort((a, b) => a.rarity - b.rarity)
.sort((a, b) => {
if (a.priority !== b.priority) {
return PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]
}
return a.selectionTimestamp - b.selectionTimestamp
})
.map((p) => p.id),
),
)
Expand All @@ -133,7 +61,7 @@ function SSPotentials({ slot, type }: SSPotentialsProps) {
const filteredPotentials = potentials
.filter(
(p) =>
(p.type === type || p.type === 'common') && !selected.includes(p.id),
(p.type === type || p.type === 'common') && !selectedIds.includes(p.id),
)
.sort((a, b) => a.rarity - b.rarity)

Expand Down Expand Up @@ -199,7 +127,7 @@ function SSPotentials({ slot, type }: SSPotentialsProps) {

<ScrollArea className="w-full rounded-sm bg-popover border">
<div className="flex min-h-[180.267px] gap-1 p-2">
{selected.length === 0 ? (
{selectedIds.length === 0 ? (
<div className="text-center self-center w-full">
<div className="h-20 w-full">
<img
Expand All @@ -210,8 +138,8 @@ function SSPotentials({ slot, type }: SSPotentialsProps) {
Please choose potentials
</div>
) : (
selected.map((s) => (
<SingleSelected key={'selected' + s} slot={slot} id={s} />
selectedIds.map((id) => (
<SingleSelected key={'selected' + id} slot={slot} id={id} />
))
)}
</div>
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const CONFIG = {
DATA_URL: 'https://raw.githubusercontent.com/maj-rf/StellaSoraData/refs/heads/main/character.json',
} as const;
4 changes: 2 additions & 2 deletions src/lib/trekker-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ export const useTrekkerStore = create<TrekkerState>()((set) => ({
...state.potentials[slot],
[p.id]:
p.rarity === 0
? { ...p, rarity: 0, priority: 'Core' }
: { ...p, level: 1, priority: 'Medium' },
? { ...p, rarity: 0, priority: 'Core', selectionTimestamp: Date.now() }
: { ...p, level: 1, priority: 'Medium', selectionTimestamp: Date.now() },
},
},
})),
Expand Down
10 changes: 5 additions & 5 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { snapdom } from '@zumer/snapdom'
import { useTrekkerStore } from './trekker-store'
import { CONFIG } from '@/config'
import type { SnapdomPlugin } from '@zumer/snapdom'
import type { ClassValue } from 'clsx'
import type { SSCharacter, TAvatar, Trekkers } from '@/types'
Expand All @@ -11,22 +12,21 @@ export function cn(...inputs: Array<ClassValue>) {
}

export async function fetchCharacters(): Promise<Record<string, SSCharacter>> {
const response = await fetch(
'https://raw.githubusercontent.com/maj-rf/StellaSoraData/refs/heads/main/character.json',
)
const response = await fetch(CONFIG.DATA_URL)
if (!response.ok) {
throw new Error('Failed to fetch characters')
}
const characters = await response.json()
return response.json()
}

export function initializeDefaultTrekkers(characters: Record<string, SSCharacter>) {
useTrekkerStore.setState({
trekkers: {
main: characters[103],
sub1: characters[112],
sub2: characters[111],
},
})
return characters
}

export function getTrekkersWithoutPotentials(trekkers: Trekkers) {
Expand Down
8 changes: 6 additions & 2 deletions src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useRef, useTransition } from 'react'
import { ResponsiveModal } from '@/components/responsive-modal'
import SSPotentials from '@/components/ss-potentials'
import { AvatarSelection } from '@/components/avatar-selection'
import { downloadImage, fetchCharacters } from '@/lib/utils'
import { downloadImage, fetchCharacters, initializeDefaultTrekkers } from '@/lib/utils'
import { Preview } from '@/components/preview'
import { Button } from '@/components/ui/button'
import { Loading } from '@/components/loading'
Expand All @@ -29,7 +29,11 @@ function AvatarPlaceholder() {

export const Route = createFileRoute('/')({
component: App,
loader: fetchCharacters,
loader: async () => {
const data = await fetchCharacters()
initializeDefaultTrekkers(data)
return data
},
pendingComponent: Loading,
})

Expand Down
Loading