feat : dd up up
CI / Verify (push) Successful in 2m34s
CI / Build immutable images and deploy (push) Successful in 4m1s

This commit is contained in:
2026-10-07 18:15:27 +07:00 Unverified
parent a53abb0e2e
commit bec24ad00b
9 changed files with 73 additions and 69 deletions
+2 -2
View File
@@ -2,9 +2,9 @@
# This is NOT the Next.js you know # This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. This version has breaking changes - APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. This block is written and re-added by `next dev` - verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules --> <!-- END:nextjs-agent-rules -->
+9 -9
View File
@@ -374,18 +374,18 @@ during a rolling update.
This project would not be possible without the open-source software and This project would not be possible without the open-source software and
community services it builds on: community services it builds on:
- [Lunaris](https://lunaris.moe/) — character, talent, material, and game data - [Lunaris](https://lunaris.moe/) - character, talent, material, and game data
used by catalog synchronization and guide tools. Buzz Guide links back to used by catalog synchronization and guide tools. Buzz Guide links back to
Lunaris for material references where appropriate. Lunaris for material references where appropriate.
- [Next.js](https://nextjs.org/) — application framework. - [Next.js](https://nextjs.org/) - application framework.
- [React](https://react.dev/) — user interface library. - [React](https://react.dev/) - user interface library.
- [Bun](https://bun.sh/) — JavaScript runtime and package manager. - [Bun](https://bun.sh/) - JavaScript runtime and package manager.
- [PostgreSQL](https://www.postgresql.org/) — primary database. - [PostgreSQL](https://www.postgresql.org/) - primary database.
- [Redis](https://redis.io/) — cache, event transport, and presence state. - [Redis](https://redis.io/) - cache, event transport, and presence state.
- [Discord.js](https://discord.js.org/) — Discord catalog update worker. - [Discord.js](https://discord.js.org/) - Discord catalog update worker.
- [Drizzle ORM](https://orm.drizzle.team/) — database schema and migrations. - [Drizzle ORM](https://orm.drizzle.team/) - database schema and migrations.
- [shadcn/ui](https://ui.shadcn.com/), [Tailwind CSS](https://tailwindcss.com/), - [shadcn/ui](https://ui.shadcn.com/), [Tailwind CSS](https://tailwindcss.com/),
[Lucide](https://lucide.dev/), and [dnd-kit](https://dndkit.com/) — interface [Lucide](https://lucide.dev/), and [dnd-kit](https://dndkit.com/) - interface
components, styling, icons, and drag-and-drop interactions. components, styling, icons, and drag-and-drop interactions.
Special thanks are also available on the site’s [Special Thanks page](https://guide.sudloh.com/thanks). Special thanks are also available on the site’s [Special Thanks page](https://guide.sudloh.com/thanks).
+1 -1
View File
@@ -73,7 +73,7 @@ async function notify(event: string, details: Record<string, unknown> = {}): Pro
log("notification_failed", { event, error: "Discord log channel is not sendable." }); log("notification_failed", { event, error: "Discord log channel is not sendable." });
return; return;
} }
await channel.send(`**Lunaris catalog worker — ${event}**\n\`\`\`json\n${JSON.stringify(details, null, 2)}\n\`\`\`${typeof details.messageUrl === "string" ? `\nTrigger message: ${details.messageUrl}` : ""}`); await channel.send(`**Lunaris catalog worker - ${event}**\n\`\`\`json\n${JSON.stringify(details, null, 2)}\n\`\`\`${typeof details.messageUrl === "string" ? `\nTrigger message: ${details.messageUrl}` : ""}`);
} catch (cause) { } catch (cause) {
log("notification_failed", { log("notification_failed", {
event, event,
@@ -1,6 +1,6 @@
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ComparisonContext, ComparisonValue } from "./comparison-context"; import { ComparisonContext } from "./comparison-context";
describe("public comparison explanations", () => { describe("public comparison explanations", () => {
it("keeps each damage metric's 100% rows separate", () => { it("keeps each damage metric's 100% rows separate", () => {
@@ -24,12 +24,4 @@ describe("public comparison explanations", () => {
expect(html).not.toContain("Only choice"); expect(html).not.toContain("Only choice");
}); });
it("renders a loss relative to the baseline but never treats an unavailable value as a loss", () => {
expect(renderToStaticMarkup(<ComparisonValue value="98.5" />)).toContain("-1.5%");
for (const value of [null, "0", "invalid"]) {
const html = renderToStaticMarkup(<ComparisonValue value={value} />);
expect(html).toContain("ไม่มีข้อมูลเปรียบเทียบ");
expect(html).not.toContain("-100%");
}
});
}); });
+1 -7
View File
@@ -1,4 +1,4 @@
import { comparisonDelta, comparisonReferenceNames } from "@/lib/guides/comparison"; import { comparisonReferenceNames } from "@/lib/guides/comparison";
type WeaponRows = Parameters<typeof comparisonReferenceNames>[0]; type WeaponRows = Parameters<typeof comparisonReferenceNames>[0];
@@ -8,7 +8,6 @@ export function ComparisonContext({ rows, metric }: {
}) { }) {
const references = rows && metric ? comparisonReferenceNames(rows, metric) : []; const references = rows && metric ? comparisonReferenceNames(rows, metric) : [];
return <div className="flex flex-col gap-1 text-sm text-muted-foreground"> return <div className="flex flex-col gap-1 text-sm text-muted-foreground">
{!rows && <p>100% = ค่าฐานของเงื่อนไขนี้ เช่น 109% หมายถึงสูงกว่าค่าฐาน 9% เปรียบเทียบภายในเงื่อนไขเดียวกัน</p>}
{rows && metric ? <p> {rows && metric ? <p>
{metric === "overallPercent" ? "ดาเมจทั้งทีม" : "ดาเมจส่วนตัว"} - {references.length {metric === "overallPercent" ? "ดาเมจทั้งทีม" : "ดาเมจส่วนตัว"} - {references.length
? `รายการที่แสดง 100%: ${references.join(" / ")}` ? `รายการที่แสดง 100%: ${references.join(" / ")}`
@@ -16,8 +15,3 @@ export function ComparisonContext({ rows, metric }: {
</p> : null} </p> : null}
</div>; </div>;
} }
export function ComparisonValue({ value }: { value: string | null }) {
const delta = comparisonDelta(value);
return <p className="text-xs opacity-75">{delta ?? "ไม่มีข้อมูลเปรียบเทียบ"}</p>;
}
+2 -2
View File
@@ -208,9 +208,9 @@ export function MaterialPoster({
className="size-[3.5cqw] shrink-0 object-contain brightness-0 invert" className="size-[3.5cqw] shrink-0 object-contain brightness-0 invert"
/> />
) : null} ) : null}
<span>{stat ? statLabel(stat.key) : "—"}</span> <span>{stat ? statLabel(stat.key) : "-"}</span>
</span> </span>
<span className="whitespace-nowrap text-[2.7cqw]">{stat ? statValue(stat.key, stat.value) : "—"}</span> <span className="whitespace-nowrap text-[2.7cqw]">{stat ? statValue(stat.key, stat.value) : "-"}</span>
</div> </div>
); );
})} })}
+29 -22
View File
@@ -2,11 +2,11 @@ import Image from "@/components/ui/resilient-image";
import localFont from "next/font/local"; import localFont from "next/font/local";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeftIcon, ArrowUpRightIcon } from "lucide-react"; import { ArrowLeftIcon, ArrowUpRightIcon } from "lucide-react";
import type { ReactNode } from "react"; import { Fragment, type ReactNode } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ComparisonContext, ComparisonValue } from "@/components/public/comparison-context"; import { ComparisonContext } from "@/components/public/comparison-context";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -76,10 +76,10 @@ export function publicSections(guide: PublicGuide) {
} }
function publicSectionTitle(slug: string, fallback: string): string { function publicSectionTitle(slug: string, fallback: string): string {
if (slug === "weapon") return "อาวุธ"; if (slug === "weapon") return "Weapons";
if (slug === "artifact") return "อาร์ติแฟกต์และค่าสถานะ"; if (slug === "artifact") return "Artifacts";
if (slug === "constellations") return "กลุ่มดาว"; if (slug === "constellations") return "Constellations";
if (slug === "team") return "ทีมแนะนำและดาเมจ"; if (slug === "team") return "Team calc";
return fallback; return fallback;
} }
@@ -96,7 +96,7 @@ export function StructuredGuideShell({
const baseHref = preview ? `/admin/${guide.characterKey}/preview` : `/${guide.slug}`; const baseHref = preview ? `/admin/${guide.characterKey}/preview` : `/${guide.slug}`;
const currentPageTitle = section const currentPageTitle = section
? publicSectionTitle(section, sections.find((item) => item.slug === section)?.title ?? section) ? publicSectionTitle(section, sections.find((item) => item.slug === section)?.title ?? section)
: "ภาพรวม"; : "Overview";
return ( return (
<div className="flex min-h-svh flex-col bg-background"> <div className="flex min-h-svh flex-col bg-background">
<LiveRefresh endpoint={`/api/events/page/${guide.id}`} /> <LiveRefresh endpoint={`/api/events/page/${guide.id}`} />
@@ -156,7 +156,7 @@ export function StructuredGuideShell({
height={20} height={20}
className="size-5 object-contain" className="size-5 object-contain"
/> />
ภาพรวม Overview
</Link> </Link>
{sections.map((item) => ( {sections.map((item) => (
<Link <Link
@@ -574,7 +574,7 @@ function WeaponSectionContent({
)} )}
> >
{Number(row.overallPercent) === 0 {Number(row.overallPercent) === 0
? (exporting ? "-%" : "—") ? (exporting ? "-%" : "-")
: metric(row.overallPercent)} : metric(row.overallPercent)}
</p> </p>
<p <p
@@ -589,7 +589,6 @@ function WeaponSectionContent({
> >
ดาเมจทั้งทีม ดาเมจทั้งทีม
</p> </p>
{!exporting && <ComparisonValue value={row.overallPercent} />}
</div> </div>
) : null} ) : null}
{row.personalPercent ? ( {row.personalPercent ? (
@@ -606,7 +605,7 @@ function WeaponSectionContent({
)} )}
> >
{Number(row.personalPercent) === 0 {Number(row.personalPercent) === 0
? (exporting ? "-%" : "—") ? (exporting ? "-%" : "-")
: metric(row.personalPercent)} : metric(row.personalPercent)}
</p> </p>
<p <p
@@ -621,7 +620,6 @@ function WeaponSectionContent({
> >
ดาเมจส่วนตัว ดาเมจส่วนตัว
</p> </p>
{!exporting && <ComparisonValue value={row.personalPercent} />}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -777,12 +775,16 @@ function ArtifactSectionContent({
)} )}
<div data-artifact-export-section="tierlist" className={exporting ? "order-first" : "lg:order-first"}> <div data-artifact-export-section="tierlist" className={exporting ? "order-first" : "lg:order-first"}>
{(() => { {(() => {
const TierList = exporting ? Fragment : Card;
const TierGroup = exporting ? Card : Fragment;
const tierCards = guide.artifactGroups.map((condition, conditionIndex) => ( const tierCards = guide.artifactGroups.map((condition, conditionIndex) => (
<div key={condition.id} data-tier-condition={conditionIndex} className="artifact-export-tierlist flex flex-col gap-5"> <div key={condition.id} data-tier-condition={conditionIndex} className="artifact-export-tierlist flex flex-col gap-5">
{!exporting && <ComparisonContext />} {!exporting && <ComparisonContext />}
{exporting && guide.artifactGroups.length > 1 ? <p className="text-sm font-medium">{condition.name || <ExportText th={`เงื่อนไข ${conditionIndex + 1}`} en={`Condition ${conditionIndex + 1}`} />}</p> : null} {exporting && guide.artifactGroups.length > 1 ? <p className="text-sm font-medium">{condition.name || <ExportText th={`เงื่อนไข ${conditionIndex + 1}`} en={`Condition ${conditionIndex + 1}`} />}</p> : null}
{condition.note ? <p data-export-note className="whitespace-pre-wrap text-sm text-muted-foreground">{condition.note}</p> : null} {condition.note ? <p data-export-note className="whitespace-pre-wrap text-sm text-muted-foreground">{condition.note}</p> : null}
{condition.tiers.map((group) => <Card key={group.id}> <TierList>
{condition.tiers.map((group, groupIndex) => <TierGroup key={group.id}>
{!exporting && groupIndex > 0 ? <Separator /> : null}
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
{group.name === "Late Game" {group.name === "Late Game"
@@ -823,14 +825,14 @@ function ArtifactSectionContent({
</div> </div>
<div className="w-24 shrink-0 text-right sm:w-28"> <div className="w-24 shrink-0 text-right sm:w-28">
<p className="text-2xl font-semibold text-primary"> <p className="text-2xl font-semibold text-primary">
{Number(row.percent) === 0 ? (exporting ? "-%" : "—") : metric(row.percent, exporting ? "en-US" : undefined)} {Number(row.percent) === 0 ? (exporting ? "-%" : "-") : metric(row.percent, exporting ? "en-US" : undefined)}
</p> </p>
{!exporting && <ComparisonValue value={row.percent} />}
</div> </div>
</div> </div>
))} ))}
</CardContent> </CardContent>
</Card>)} </TierGroup>)}
</TierList>
</div> </div>
)); ));
return exporting ? tierCards : <ConditionSwitcher label="เลือกเงื่อนไขอันดับอาร์ติแฟกต์" labels={guide.artifactGroups.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`)} iconUrls={guide.artifactGroups.map((condition) => condition.iconUrl)}>{tierCards}</ConditionSwitcher>; return exporting ? tierCards : <ConditionSwitcher label="เลือกเงื่อนไขอันดับอาร์ติแฟกต์" labels={guide.artifactGroups.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`)} iconUrls={guide.artifactGroups.map((condition) => condition.iconUrl)}>{tierCards}</ConditionSwitcher>;
@@ -1203,7 +1205,6 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
</p> </p>
) : null} ) : null}
</header> </header>
<p className="text-sm text-muted-foreground">ดาเมจทั้งทีมสะสมเทียบกับ C0 = 100% เช่น 117% คือสูงกว่า C0 17% ภายใต้เงื่อนไขเดียวกัน</p>
<ConditionSwitcher label="เลือกเงื่อนไขกลุ่มดาว" labels={guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)} iconUrls={guide.constellationGroups.filter((group) => group.rows.length > 0).map((group) => group.iconUrl)}> <ConditionSwitcher label="เลือกเงื่อนไขกลุ่มดาว" labels={guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)} iconUrls={guide.constellationGroups.filter((group) => group.rows.length > 0).map((group) => group.iconUrl)}>
{guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, groupIndex, groups) => ( {guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, groupIndex, groups) => (
<div key={group.id} className="flex flex-col gap-4"> <div key={group.id} className="flex flex-col gap-4">
@@ -1245,9 +1246,8 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
row.isGreat && "text-warning", row.isGreat && "text-warning",
)} )}
> >
{Number(row.cumulativePercent) === 0 ? "—" : metric(row.cumulativePercent)} {Number(row.cumulativePercent) === 0 ? "-" : metric(row.cumulativePercent)}
</CardTitle> </CardTitle>
<ComparisonValue value={row.cumulativePercent} />
<CardDescription className="text-base font-medium text-white"> <CardDescription className="text-base font-medium text-white">
C{row.level} - {row.name} C{row.level} - {row.name}
</CardDescription> </CardDescription>
@@ -1271,9 +1271,16 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
function TeamSection({ guide }: { guide: PublicGuide }) { function TeamSection({ guide }: { guide: PublicGuide }) {
return ( return (
<section className="flex flex-col gap-6"> <section className="flex flex-col gap-6">
<header className="text-center"> <header className="flex items-center justify-center gap-3">
<h1 className="font-heading text-3xl font-semibold sm:text-4xl">ทีมแนะนำและดาเมจ</h1> <Image
<p className="mt-3 text-sm text-muted-foreground">ผลคำนวณสำหรับทีม อาวุธ กลุ่มดาว และเงื่อนไขที่ระบุในไกด์ เลือกดูและเปรียบเทียบทีมที่มีอยู่ได้</p> src="/icon/nav/Team-calc.webp"
alt=""
width={56}
height={56}
loading="lazy"
className="size-14 shrink-0 object-contain"
/>
<h1 className="font-heading text-3xl font-semibold sm:text-4xl">ทีมและดาเมจ</h1>
</header> </header>
<TeamSearch groups={guide.teamGroups} /> <TeamSearch groups={guide.teamGroups} />
</section> </section>
+1 -2
View File
@@ -388,7 +388,6 @@ function TeamCard({
<p className="mt-1 truncate text-sm font-medium"> <p className="mt-1 truncate text-sm font-medium">
{member.character.name} {member.character.name}
</p> </p>
{!exporting && <p className="text-xs text-muted-foreground">ดาเมจต่อโรเทชั่น</p>}
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">
{Number(member.damage).toLocaleString(undefined, { {Number(member.damage).toLocaleString(undefined, {
maximumFractionDigits: 2, maximumFractionDigits: 2,
@@ -398,7 +397,7 @@ function TeamCard({
{total {total
? ((Number(member.damage) / total) * 100).toFixed(2) ? ((Number(member.damage) / total) * 100).toFixed(2)
: "0.00"} : "0.00"}
%{!exporting && " ของทีม"} %
</p> </p>
</div> </div>
))} ))}
+27 -15
View File
@@ -1,6 +1,8 @@
"use client"; "use client";
import { Bar, BarChart, CartesianGrid, LabelList, ReferenceLine, XAxis, YAxis } from "recharts"; import { Bar, BarChart, CartesianGrid, LabelList, ReferenceLine, XAxis, YAxis } from "recharts";
import Image from "@/components/ui/resilient-image";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
ChartContainer, ChartLegend, ChartLegendContent, ChartContainer, ChartLegend, ChartLegendContent,
@@ -22,6 +24,8 @@ function percentage(value: string | null) {
export function WeaponChart({ rows }: { rows: PublicGuide["weaponGroups"][number]["rows"] }) { export function WeaponChart({ rows }: { rows: PublicGuide["weaponGroups"][number]["rows"] }) {
const data = rows.map((row, index) => ({ const data = rows.map((row, index) => ({
name: `${index + 1}. ${row.weapon.name} R${row.refinement}`, name: `${index + 1}. ${row.weapon.name} R${row.refinement}`,
weapon: row.weapon,
refinement: row.refinement,
overall: percentage(row.overallPercent), overall: percentage(row.overallPercent),
personal: percentage(row.personalPercent), personal: percentage(row.personalPercent),
})); }));
@@ -38,30 +42,38 @@ export function WeaponChart({ rows }: { rows: PublicGuide["weaponGroups"][number
</CardHeader> </CardHeader>
<CardContent className="min-w-0"> <CardContent className="min-w-0">
<ChartContainer config={chartConfig} className="aspect-auto min-w-0 w-full" <ChartContainer config={chartConfig} className="aspect-auto min-w-0 w-full"
style={{ height: Math.max(240, data.length * 72 + 80) }} style={{ height: 420 }}
aria-label="กราฟเปรียบเทียบเปอร์เซ็นต์ดาเมจอาวุธ"> aria-label="กราฟเปรียบเทียบเปอร์เซ็นต์ดาเมจอาวุธ">
<BarChart accessibilityLayer data={data} layout="vertical" margin={{ right: 42, left: 0 }}> <BarChart accessibilityLayer data={data} layout="horizontal" barGap={4} margin={{ top: 28, right: 12, left: 0 }}>
<CartesianGrid horizontal={false} /> <CartesianGrid vertical={false} />
<XAxis type="number" tickLine={false} axisLine={false} <YAxis type="number" tickLine={false} axisLine={false}
domain={[(minimum: number) => Math.min(0, minimum), (maximum: number) => Math.max(110, maximum)]} domain={[(minimum: number) => Math.min(0, minimum), (maximum: number) => Math.max(110, maximum)]}
tickFormatter={(value) => `${value}%`} /> tickFormatter={(value) => `${value}%`} />
<YAxis dataKey="name" type="category" tickLine={false} axisLine={false} width={142} interval={0} <XAxis dataKey="name" type="category" tickLine={false} axisLine={false} height={96} interval={0}
tick={({ x, y, payload }) => <g transform={`translate(${x},${y})`}> tick={({ x, y, width, payload }) => {
<foreignObject x={-140} y={-32} width={132} height={64}> const row = data.find((item) => item.name === payload.value);
<div className="flex h-full items-center justify-end text-right text-xs leading-4 wrap-anywhere text-foreground">{payload.value}</div> if (!row) return <g />;
</foreignObject> const scale = Math.min(1, Math.max(0, Number(width) / data.length - 4) / 56);
</g>} /> return <g transform={`translate(${x},${Number(y) + 8}) scale(${scale})`}>
<ReferenceLine x={100} stroke="var(--muted-foreground)" strokeDasharray="4 4" /> <foreignObject x={-28} y={0} width={56} height={80}>
<div className="flex flex-col items-center gap-1" title={row.name}>
<Image src={row.weapon.imageUrl} alt={row.weapon.name} width={56} height={56} className="size-14 object-contain" />
<Badge variant="secondary">R{row.refinement}</Badge>
</div>
</foreignObject>
</g>;
}} />
<ReferenceLine y={100} stroke="var(--muted-foreground)" strokeDasharray="4 4" />
<ChartTooltip content={<ChartTooltipContent formatter={(value, name) => <div className="flex flex-1 items-center justify-between gap-4"> <ChartTooltip content={<ChartTooltipContent formatter={(value, name) => <div className="flex flex-1 items-center justify-between gap-4">
<span className="text-muted-foreground">{chartConfig[name as keyof typeof chartConfig]?.label ?? name}</span> <span className="text-muted-foreground">{chartConfig[name as keyof typeof chartConfig]?.label ?? name}</span>
<span className="font-mono font-medium">{value}%</span> <span className="font-mono font-medium">{value}%</span>
</div>} />} /> </div>} />} />
<ChartLegend content={<ChartLegendContent className="flex-wrap" />} /> <ChartLegend content={<ChartLegendContent className="flex-wrap" />} />
{hasPersonal && <Bar dataKey="personal" fill="var(--color-personal)" radius={4} maxBarSize={singleMetric ? 26 : 18} isAnimationActive={false}> {hasPersonal && <Bar dataKey="personal" fill="var(--color-personal)" radius={4} maxBarSize={singleMetric ? 48 : 40} isAnimationActive={false}>
<LabelList position="right" formatter={(value) => typeof value === "number" ? `${value.toLocaleString("th-TH", { maximumFractionDigits: 2 })}%` : ""} className="fill-foreground text-xs" /> <LabelList position="top" formatter={(value) => typeof value === "number" ? `${value.toLocaleString("th-TH", { maximumFractionDigits: 2 })}%` : ""} className="fill-foreground text-xs" />
</Bar>} </Bar>}
{hasOverall && <Bar dataKey="overall" fill="var(--color-overall)" radius={4} maxBarSize={singleMetric ? 26 : 18} isAnimationActive={false}> {hasOverall && <Bar dataKey="overall" fill="var(--color-overall)" radius={4} maxBarSize={singleMetric ? 48 : 40} isAnimationActive={false}>
<LabelList position="right" formatter={(value) => typeof value === "number" ? `${value.toLocaleString("th-TH", { maximumFractionDigits: 2 })}%` : ""} className="fill-foreground text-xs" /> <LabelList position="top" formatter={(value) => typeof value === "number" ? `${value.toLocaleString("th-TH", { maximumFractionDigits: 2 })}%` : ""} className="fill-foreground text-xs" />
</Bar>} </Bar>}
</BarChart> </BarChart>
</ChartContainer> </ChartContainer>