feat : update
CI / Verify (push) Successful in 50s
CI / Build immutable images and deploy (push) Successful in 4m15s

This commit is contained in:
2026-08-30 11:17:58 +00:00 Unverified
parent fd3472bc8d
commit 8177c91434
9 changed files with 257 additions and 18 deletions
+11
View File
@@ -6,6 +6,9 @@
"name": "buzz-sheet",
"dependencies": {
"@base-ui/react": "^1.7.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"better-auth": "^1.7.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -134,6 +137,14 @@
"@better-fetch/fetch": ["@better-fetch/[email protected]", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="],
"@dnd-kit/accessibility": ["@dnd-kit/[email protected]", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
"@dnd-kit/core": ["@dnd-kit/[email protected]", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
"@dnd-kit/sortable": ["@dnd-kit/[email protected]", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
"@dnd-kit/utilities": ["@dnd-kit/[email protected]", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
"@dotenvx/dotenvx": ["@dotenvx/[email protected]", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="],
"@dotenvx/primitives": ["@dotenvx/[email protected]", "", {}, "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg=="],
+135 -8
View File
@@ -1,8 +1,24 @@
"use client";
import Image from "next/image";
import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { ArrowDownIcon, ArrowUpIcon, GripVerticalIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
saveArtifactSection,
@@ -43,13 +59,19 @@ import {
} from "@/lib/guides/schemas";
import { supportsWeaponType } from "@/lib/catalog/lunaris";
import { rarityGradientClass } from "@/lib/catalog/rarity";
import {
normalizeTalentPriorities,
reorderTalentPriorities,
type TalentPriority,
type TalentRelation,
} from "@/lib/guides/talents";
import { cn } from "@/lib/utils";
type CatalogItem = { key: string; name: string; imageUrl: string; rarity?: number | null; weaponType?: string | null };
type Guide = { id: string; characterKey: string; version: number; name: string };
type WeaponValue = { weaponKey: string; refinement: number; overallPercent: string | null; personalPercent: string | null; weapon: CatalogItem };
type ArtifactValue = { id: string; name: string; rows: Array<{ firstArtifactKey: string; secondArtifactKey: string | null; percent: string }> };
type ArtifactProfile = { sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; targets: Array<{ stat: string; value: string }>; talents: Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string }>; note: string | null } | null;
type ArtifactProfile = { sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; targets: Array<{ stat: string; value: string }>; talents: TalentPriority[]; note: string | null } | null;
type ConstellationValue = { level: number; detail: string; cumulativePercent: string };
type CatalogConstellation = { level: number; name: string; description: string; iconUrl: string | null };
type CatalogTalent = { type: "NA" | "E" | "Q"; name: string; iconUrl: string | null };
@@ -119,6 +141,46 @@ function TalentLevelPicker({ value, onChange }: { value?: string; onChange: (val
);
}
function SortableTalentCard({
talent,
index,
catalogTalent,
onLevelChange,
}: {
talent: TalentPriority;
index: number;
catalogTalent?: CatalogTalent;
onLevelChange: (level?: string) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: talent.type });
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(isDragging && "opacity-60")}
>
<Card size="sm">
<CardHeader>
{catalogTalent?.iconUrl ? <Image src={catalogTalent.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /> : null}
<CardTitle>{index + 1}. {catalogTalent?.name ?? talent.type}</CardTitle>
<CardAction>
<Button type="button" variant="ghost" size="icon-sm" className="touch-none" {...attributes} {...listeners}>
<GripVerticalIcon />
<span className="sr-only">ลากเพื่อจัดลำดับ {catalogTalent?.name ?? talent.type}</span>
</Button>
</CardAction>
</CardHeader>
<CardContent>
<Field>
<FieldLabel>ระดับเป้าหมาย</FieldLabel>
<TalentLevelPicker value={talent.level} onChange={onLevelChange} />
</Field>
</CardContent>
</Card>
</div>
);
}
function RowActions({ index, length, move, remove }: { index: number; length: number; move: (from: number, to: number) => void; remove?: () => void }) {
return <div className="flex gap-1"><Button type="button" variant="ghost" size="icon-sm" disabled={index === 0} onClick={() => move(index, index - 1)}><ArrowUpIcon /><span className="sr-only">เลื่อนขึ้น</span></Button><Button type="button" variant="ghost" size="icon-sm" disabled={index === length - 1} onClick={() => move(index, index + 1)}><ArrowDownIcon /><span className="sr-only">เลื่อนลง</span></Button>{remove ? <Button type="button" variant="ghost" size="icon-sm" onClick={remove}><Trash2Icon /><span className="sr-only">ลบ</span></Button> : null}</div>;
}
@@ -163,6 +225,10 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBase) {
const profile = initial.artifactProfile;
const talentSensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const [groups, setGroups] = useState(() => (["Late Game", "Early Game"] as const).map((name, index) => ({
name,
rows: initial.artifactGroups[index]?.rows.map((row) => ({ ...row })) ?? [],
@@ -176,19 +242,26 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
const defaults = DEFAULT_TARGET_STATS.map((stat) => existing.find((target) => target.stat === stat) ?? { stat, value: "" });
return [...defaults, ...existing.filter((target) => !DEFAULT_TARGET_STATS.includes(target.stat as (typeof DEFAULT_TARGET_STATS)[number]))];
});
const [talents, setTalents] = useState<Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string }>>(() => {
const source: Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string }> = profile?.talents.length
const [talents, setTalents] = useState<TalentPriority[]>(() => {
const source: TalentPriority[] = profile?.talents.length
? profile.talents
: (["NA", "E", "Q"] as const).map((type, priority) => ({ type, priority: priority + 1 }));
return source.map((talent) => ({
return normalizeTalentPriorities(source.map((talent) => ({
...talent,
level: talent.level && talentLevelOptions.includes(talent.level as (typeof talentLevelOptions)[number]) ? talent.level : undefined,
}));
})));
});
const [note, setNote] = useState(profile?.note ?? "");
const value = { groups: groups.map((group) => ({ ...group, rows: group.rows.map((row) => ({ ...row, percent: Number(row.percent) })) })), sands, goblet, circlet, substats, targets: targets.filter((target) => target.stat.trim() && target.value.trim()), talents, note: note || null };
const autosave = useAutosave({ value, valid: artifactSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveArtifactSection(guide.id, { ...next, expectedVersion }) });
const orderedTalents = [...talents].sort((a, b) => a.priority - b.priority);
const orderedTalents = normalizeTalentPriorities(talents);
const handleTalentDragEnd = ({ active, over }: DragEndEvent) => {
if (!over) return;
setTalents(reorderTalentPriorities(orderedTalents, active.id as TalentPriority["type"], over.id as TalentPriority["type"]));
};
const setTalentRelation = (type: TalentPriority["type"], relationToNext: TalentRelation) => {
setTalents(orderedTalents.map((talent) => talent.type === type ? { ...talent, relationToNext } : talent));
};
return (
<div className="flex flex-col gap-6">
<Card>
@@ -199,7 +272,61 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
</Card>
<Card>
<CardHeader><CardTitle>Recommended Stats</CardTitle><CardDescription>เลือกค่าจากรายการแทนปุ่ม และเลือก Main Stat ได้สูงสุด 2 ค่าต่อชิ้น</CardDescription></CardHeader>
<CardContent><FieldGroup><StatSelect label="Sands / นาฬิกา" options={mainStatOptions.sands} value={sands} max={2} onChange={setSands} /><StatSelect label="Goblet / ถ้วย" options={mainStatOptions.goblet} value={goblet} max={2} onChange={setGoblet} /><StatSelect label="Circlet / หมวก" options={mainStatOptions.circlet} value={circlet} max={2} onChange={setCirclet} /><StatSelect label="Substats" options={substatOptions} value={substats} max={substatOptions.length} onChange={setSubstats} /><FieldSet><FieldLegend>Target Stats</FieldLegend>{targets.map((target, index) => <div key={`${target.stat}-${index}`} className="grid gap-3 sm:grid-cols-[1fr_1fr_auto]"><Input value={target.stat} placeholder="ATK" aria-label={`Target stat ${index + 1}`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, stat: event.target.value } : item))} /><Input value={target.value} placeholder={target.stat === "ATK" ? "2200+" : "Recommended value"} aria-label={`${target.stat || "Target"} value`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} /><Button variant="ghost" size="icon" onClick={() => setTargets(targets.filter((_, itemIndex) => itemIndex !== index))}><Trash2Icon /><span className="sr-only">ลบเป้าหมาย</span></Button></div>)}<Button variant="outline" onClick={() => setTargets([...targets, { stat: "", value: "" }])}><PlusIcon data-icon="inline-start" />เพิ่ม Target</Button></FieldSet><FieldSet><FieldLegend>Talent Priority</FieldLegend>{orderedTalents.map((talent, index) => { const catalogTalent = catalogTalents.find((item) => item.type === talent.type); return <Card key={talent.type} size="sm"><CardHeader>{catalogTalent?.iconUrl ? <Image src={catalogTalent.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /> : null}<CardTitle>{index + 1}. {catalogTalent?.name ?? talent.type}</CardTitle><CardAction><RowActions index={index} length={orderedTalents.length} move={(from, to) => setTalents(moveItem(orderedTalents, from, to).map((item, priority) => ({ ...item, priority: priority + 1 })))} /></CardAction></CardHeader><CardContent><Field><FieldLabel>ระดับเป้าหมาย</FieldLabel><TalentLevelPicker value={talent.level} onChange={(level) => setTalents(talents.map((item) => item.type === talent.type ? { ...item, level } : item))} /></Field></CardContent></Card>; })}</FieldSet><Field><FieldLabel htmlFor="artifact-note">Note (optional)</FieldLabel><Textarea id="artifact-note" value={note} onChange={(event) => setNote(event.target.value)} /></Field></FieldGroup></CardContent>
<CardContent>
<FieldGroup>
<StatSelect label="Sands / นาฬิกา" options={mainStatOptions.sands} value={sands} max={2} onChange={setSands} />
<StatSelect label="Goblet / ถ้วย" options={mainStatOptions.goblet} value={goblet} max={2} onChange={setGoblet} />
<StatSelect label="Circlet / หมวก" options={mainStatOptions.circlet} value={circlet} max={2} onChange={setCirclet} />
<StatSelect label="Substats" options={substatOptions} value={substats} max={substatOptions.length} onChange={setSubstats} />
<FieldSet>
<FieldLegend>Target Stats</FieldLegend>
{targets.map((target, index) => <div key={`${target.stat}-${index}`} className="grid gap-3 sm:grid-cols-[1fr_1fr_auto]"><Input value={target.stat} placeholder="ATK" aria-label={`Target stat ${index + 1}`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, stat: event.target.value } : item))} /><Input value={target.value} placeholder={target.stat === "ATK" ? "2200+" : "Recommended value"} aria-label={`${target.stat || "Target"} value`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} /><Button variant="ghost" size="icon" onClick={() => setTargets(targets.filter((_, itemIndex) => itemIndex !== index))}><Trash2Icon /><span className="sr-only">ลบเป้าหมาย</span></Button></div>)}
<Button variant="outline" onClick={() => setTargets([...targets, { stat: "", value: "" }])}><PlusIcon data-icon="inline-start" />เพิ่ม Target</Button>
</FieldSet>
<FieldSet>
<FieldLegend>Talent Priority</FieldLegend>
<DndContext sensors={talentSensors} collisionDetection={closestCenter} onDragEnd={handleTalentDragEnd}>
<SortableContext items={orderedTalents.map((talent) => talent.type)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-3">
{orderedTalents.map((talent, index) => {
const catalogTalent = catalogTalents.find((item) => item.type === talent.type);
return (
<div key={talent.type} className="flex flex-col gap-2">
<SortableTalentCard
talent={talent}
index={index}
catalogTalent={catalogTalent}
onLevelChange={(level) => setTalents(orderedTalents.map((item) => item.type === talent.type ? { ...item, level } : item))}
/>
{index < orderedTalents.length - 1 ? (
<ToggleGroup
variant="outline"
spacing={0}
value={[talent.relationToNext ?? "gt"]}
onValueChange={(selected) => {
const relation = selected[0] as TalentRelation | undefined;
if (relation) setTalentRelation(talent.type, relation);
}}
aria-label={`ตัวเปรียบเทียบหลัง ${catalogTalent?.name ?? talent.type}`}
className="self-center"
>
<ToggleGroupItem className={SELECTED_TOGGLE_CLASS} value="gt" aria-label="มากกว่า">&gt;</ToggleGroupItem>
<ToggleGroupItem className={SELECTED_TOGGLE_CLASS} value="gte" aria-label="มากกว่าหรือเท่ากับ">≥</ToggleGroupItem>
</ToggleGroup>
) : null}
</div>
);
})}
</div>
</SortableContext>
</DndContext>
</FieldSet>
<Field>
<FieldLabel htmlFor="artifact-note">Note (optional)</FieldLabel>
<Textarea id="artifact-note" value={note} onChange={(event) => setNote(event.target.value)} />
</Field>
</FieldGroup>
</CardContent>
<CardFooter className="justify-end"><AutosaveStatus state={autosave} /></CardFooter>
</Card>
</div>
+20 -9
View File
@@ -19,6 +19,7 @@ import { ElementIcon } from "@/components/public/element-icon";
import { LiveRefresh } from "@/components/live-refresh";
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
import type { PublicGuide } from "@/lib/guides/queries";
import { normalizeTalentPriorities, talentRelationSymbol } from "@/lib/guides/talents";
import { cn } from "@/lib/utils";
function hasSection(guide: PublicGuide, slug: string): boolean {
@@ -374,17 +375,27 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
{profile.talents.length ? (
<div>
<p className="mb-2 text-sm font-medium">ลำดับการอัพพรสวรรค์</p>
<div className="flex flex-wrap gap-3">
{[...profile.talents]
.sort((a, b) => a.priority - b.priority)
.map((talent) => {
<div className="flex flex-wrap items-start justify-center gap-3">
{normalizeTalentPriorities(profile.talents)
.map((talent, index, talents) => {
const catalogTalent = guide.character.talents.find((item) => item.type === talent.type);
return catalogTalent?.iconUrl ? (
<div key={talent.type} className="relative size-14 overflow-hidden rounded-lg border bg-muted">
<Image src={catalogTalent.iconUrl} alt="" fill className="object-contain" />
<Kbd className="absolute top-0 right-0">{talent.priority}</Kbd>
const talentName = catalogTalent?.name ?? talent.type;
return (
<div key={talent.type} className="flex items-start gap-3">
<div className="flex w-28 flex-col items-center gap-2 text-center">
<div className="relative size-20 overflow-hidden rounded-xl border bg-muted">
{catalogTalent?.iconUrl ? <Image src={catalogTalent.iconUrl} alt={talentName} fill className="object-contain" /> : <span className="grid size-full place-items-center font-semibold">{talent.type}</span>}
</div>
<Badge variant="secondary">{talent.level ? `Lv.${talent.level}+` : "—"}</Badge>
<p className="text-sm font-medium">{talentName}</p>
</div>
{index < talents.length - 1 ? (
<span className="mt-6 text-3xl font-semibold" aria-label={talent.relationToNext === "gte" ? "มากกว่าหรือเท่ากับ" : "มากกว่า"}>
{talentRelationSymbol(talent.relationToNext)}
</span>
) : null}
</div>
) : <Badge key={talent.type} variant="secondary">{talent.priority}. {talent.type}</Badge>;
);
})}
</div>
</div>
+1 -1
View File
@@ -606,7 +606,7 @@ export const artifactProfiles = guidesSchema.table("artifact_profile", {
circlet: jsonb("circlet").$type<string[]>().default([]).notNull(),
substats: jsonb("substats").$type<string[]>().default([]).notNull(),
targets: jsonb("targets").$type<Array<{ stat: string; value: string }>>().default([]).notNull(),
talents: jsonb("talents").$type<Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string }>>().default([]).notNull(),
talents: jsonb("talents").$type<Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string; relationToNext?: "gt" | "gte" }>>().default([]).notNull(),
note: text("note"),
...timestamps,
});
+7
View File
@@ -35,6 +35,13 @@ describe("structured guide validation", () => {
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "NA", priority: 1, level: "11" }] }).success).toBe(false);
});
it("accepts talent comparison relations while keeping legacy talents valid", () => {
const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], targets: [], note: null };
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1, relationToNext: "gte" }] }).success).toBe(true);
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1 }] }).success).toBe(true);
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1, relationToNext: "equal" }] }).success).toBe(false);
});
it("requires four unique team members and non-zero damage", () => {
const member = (characterKey: string) => ({ characterKey, weaponKey: null, artifactKey: null, constellation: 0, refinement: 1, damage: 100 });
const base = { expectedVersion: 1, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }] };
+2
View File
@@ -38,6 +38,7 @@ export const mainStatOptions = {
export const substatOptions = ["CRIT Rate%/CRIT DMG%", "HP%", "ATK%", "DEF%", "EM", "ER"] as const;
export const talentLevelOptions = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"] as const;
export const talentRelationOptions = ["gt", "gte"] as const;
const artifactGroupSchema = (name: "Late Game" | "Early Game") => z.strictObject({
name: z.literal(name),
@@ -66,6 +67,7 @@ export const artifactSectionSchema = z.strictObject({
type: z.enum(["NA", "E", "Q"]),
priority: z.number().int().min(1).max(3),
level: z.enum(talentLevelOptions).optional(),
relationToNext: z.enum(talentRelationOptions).optional(),
})).max(3),
note: z.string().trim().max(2_000).nullable(),
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { normalizeTalentPriorities, reorderTalentPriorities, talentRelationSymbol } from "@/lib/guides/talents";
describe("talent priorities", () => {
it("normalizes legacy talents to strict comparisons", () => {
expect(normalizeTalentPriorities([
{ type: "Q", priority: 2 },
{ type: "E", priority: 1 },
{ type: "NA", priority: 3 },
])).toEqual([
{ type: "E", priority: 1, relationToNext: "gt" },
{ type: "Q", priority: 2, relationToNext: "gt" },
{ type: "NA", priority: 3, relationToNext: undefined },
]);
});
it("reorders talents while preserving relations between positions", () => {
expect(reorderTalentPriorities([
{ type: "E", priority: 1, relationToNext: "gte" },
{ type: "Q", priority: 2, relationToNext: "gt" },
{ type: "NA", priority: 3 },
], "NA", "E")).toEqual([
{ type: "NA", priority: 1, relationToNext: "gte" },
{ type: "E", priority: 2, relationToNext: "gt" },
{ type: "Q", priority: 3, relationToNext: undefined },
]);
});
it("formats strict and greater-than-or-equal relations", () => {
expect(talentRelationSymbol("gt")).toBe(">");
expect(talentRelationSymbol("gte")).toBe("≥");
expect(talentRelationSymbol()).toBe(">");
});
});
+43
View File
@@ -0,0 +1,43 @@
export type TalentRelation = "gt" | "gte";
export interface TalentPriority {
type: "NA" | "E" | "Q";
priority: number;
level?: string;
relationToNext?: TalentRelation;
}
export function normalizeTalentPriorities(talents: readonly TalentPriority[]): TalentPriority[] {
const ordered = [...talents].sort((a, b) => a.priority - b.priority);
return ordered.map((talent, index) => ({
...talent,
priority: index + 1,
relationToNext: index < ordered.length - 1 ? talent.relationToNext ?? "gt" : undefined,
}));
}
export function reorderTalentPriorities(
talents: readonly TalentPriority[],
activeType: TalentPriority["type"],
overType: TalentPriority["type"],
): TalentPriority[] {
const ordered = normalizeTalentPriorities(talents);
const from = ordered.findIndex((talent) => talent.type === activeType);
const to = ordered.findIndex((talent) => talent.type === overType);
if (from < 0 || to < 0 || from === to) return ordered;
const relations = ordered.slice(0, -1).map((talent) => talent.relationToNext ?? "gt");
const next = [...ordered];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next.map((talent, index) => ({
...talent,
priority: index + 1,
relationToNext: index < next.length - 1 ? relations[index] : undefined,
}));
}
export function talentRelationSymbol(relation?: TalentRelation): ">" | "≥" {
return relation === "gte" ? "≥" : ">";
}
+3
View File
@@ -20,6 +20,9 @@
},
"dependencies": {
"@base-ui/react": "^1.7.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"better-auth": "^1.7.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",