Build stats / popular builds (#1307)

* Add abilityPoints migration

* Initial stats

* Build stats UI

* Add TODO

* Update css file name

* Popular builds page
This commit is contained in:
Kalle 2023-03-11 14:05:08 +02:00 committed by GitHub
parent 3a7549c732
commit cfa0d4dd0d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 845 additions and 4 deletions

View File

@ -0,0 +1,27 @@
import type { CSSProperties } from "react";
export function ChartBarIcon({
className,
style,
}: {
className?: string;
style?: CSSProperties;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={className}
style={style}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
);
}

View File

@ -0,0 +1,32 @@
import type { CSSProperties } from "react";
export function FireIcon({
className,
style,
}: {
className?: string;
style?: CSSProperties;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={className}
style={style}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.21 0 003 2.48z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 18a3.75 3.75 0 00.495-7.467 5.99 5.99 0 00-1.925 3.546 5.974 5.974 0 01-2.133-1A3.75 3.75 0 0012 18z"
/>
</svg>
);
}

View File

@ -69,3 +69,5 @@ export const ADMIN_ID = process.env.NODE_ENV === "production" ? 274 : 1;
export const LOHI_TOKEN_HEADER_NAME = "Lohi-Token";
export const CUSTOMIZED_CSS_VARS_NAME = "css";
export const MAX_AP = 57;

View File

@ -167,6 +167,7 @@ export interface BuildAbility {
gearType: "HEAD" | "CLOTHES" | "SHOES";
ability: Ability;
slotIndex: 0 | 1 | 2 | 3;
abilityPoints: number; // 3 or 10
}
export interface MapPoolMap {

View File

@ -2,7 +2,6 @@ import type { DamageType } from "./analyzer-types";
import { type MainWeaponId } from "~/modules/in-game-lists";
export const MAX_LDE_INTENSITY = 21;
export const MAX_AP = 57;
export const DAMAGE_TYPE = [
"NORMAL_MIN",

View File

@ -1,4 +1,4 @@
import { MAX_AP } from "../analyzer-constants";
import { MAX_AP } from "~/constants";
import type { AbilityPoints } from "../analyzer-types";
export const SPECIAL_EFFECTS = [

View File

@ -0,0 +1,150 @@
import { suite } from "uvu";
import * as assert from "uvu/assert";
import {
abilityPointCountsToAverages,
popularBuilds,
} from "./build-stats-utils";
const AbilityPointCountsToAverages = suite("abilityPointCountsToAverages()");
const PopularBuilds = suite("popularBuilds()");
const commonAbilities = [
{
ability: "QR" as const,
abilityPointsSum: 57,
},
{
ability: "SJ" as const,
abilityPointsSum: 10,
},
{
ability: "CB" as const,
abilityPointsSum: 10,
},
{
ability: "T" as const,
abilityPointsSum: 10,
},
{
ability: "SS" as const,
abilityPointsSum: 27,
},
];
const allAbilities = [
...commonAbilities,
{ ability: "BRU" as const, abilityPointsSum: 57 },
];
AbilityPointCountsToAverages("calculates build count", () => {
const { weaponBuildsCount } = abilityPointCountsToAverages({
allAbilities,
weaponAbilities: commonAbilities,
});
assert.is(weaponBuildsCount, 2);
});
AbilityPointCountsToAverages("calculates average ap (main only)", () => {
const { mainOnlyAbilities } = abilityPointCountsToAverages({
allAbilities,
weaponAbilities: commonAbilities,
});
assert.is(
mainOnlyAbilities.find((a) => a.name === "T")?.percentage.weapon,
50
);
});
AbilityPointCountsToAverages("calculates average ap (stackable)", () => {
const { stackableAbilities } = abilityPointCountsToAverages({
allAbilities,
weaponAbilities: commonAbilities,
});
assert.is(
stackableAbilities.find((a) => a.name === "SS")?.apAverage.weapon,
13.5
);
});
AbilityPointCountsToAverages("calculates average ap for all builds", () => {
const { mainOnlyAbilities } = abilityPointCountsToAverages({
allAbilities,
weaponAbilities: commonAbilities,
});
assert.is(
mainOnlyAbilities.find((a) => a.name === "T")?.percentage.all,
33.33
);
});
PopularBuilds("calculates popular build", () => {
const builds = popularBuilds([
...new Array(10).fill(null).map(() => ({
abilities: [{ ability: "QR" as const, abilityPoints: 57 }],
})),
{
abilities: [{ ability: "BRU" as const, abilityPoints: 57 }],
},
]);
assert.is(builds.length, 1);
assert.is(builds[0]!.count, 10);
assert.is(builds[0]!.abilities[0]!.ability, "QR");
});
PopularBuilds("calculates second most popular build (sorted by count)", () => {
const builds = popularBuilds([
...new Array(10).fill(null).map(() => ({
abilities: [{ ability: "QR" as const, abilityPoints: 57 }],
})),
...new Array(3).fill(null).map(() => ({
abilities: [{ ability: "SS" as const, abilityPoints: 57 }],
})),
...new Array(5).fill(null).map(() => ({
abilities: [{ ability: "SSU" as const, abilityPoints: 57 }],
})),
]);
assert.is(builds.length, 3);
assert.is(builds[1]!.abilities[0]!.ability, "SSU");
});
PopularBuilds("sums up abilities", () => {
const builds = popularBuilds([
{ abilities: [{ ability: "QR" as const, abilityPoints: 57 }] },
{
abilities: [
{ ability: "QR" as const, abilityPoints: 10 },
{ ability: "QR" as const, abilityPoints: 47 },
],
},
]);
assert.is(builds.length, 1);
});
PopularBuilds("sorts abilities", () => {
const builds = popularBuilds([
{
abilities: [
{ ability: "QR" as const, abilityPoints: 10 },
{ ability: "SS" as const, abilityPoints: 47 },
],
},
{
abilities: [
{ ability: "QR" as const, abilityPoints: 10 },
{ ability: "SS" as const, abilityPoints: 47 },
],
},
]);
assert.is(builds[0]!.abilities[1]!.ability, "QR");
});
AbilityPointCountsToAverages.run();
PopularBuilds.run();

View File

@ -0,0 +1,161 @@
import invariant from "tiny-invariant";
import { MAX_AP } from "~/constants";
import { abilities, type Ability } from "~/modules/in-game-lists";
import { roundToNDecimalPlaces } from "~/utils/number";
import { isStackableAbility } from "../build-analyzer/core/utils";
import type { AbilitiesByWeapon } from "./queries/abilitiesByWeaponId.server";
import type { AverageAbilityPointsResult } from "./queries/averageAbilityPoints.server";
const toBuildsCount = (counts: AverageAbilityPointsResult[]) =>
counts.reduce((acc, cur) => acc + cur.abilityPointsSum, 0) / MAX_AP;
const toAbilityPoints = (
abilities: AverageAbilityPointsResult[],
ability: Ability
) =>
abilities.find((current) => current.ability === ability)?.abilityPointsSum ??
0;
export function abilityPointCountsToAverages({
allAbilities,
weaponAbilities,
}: {
allAbilities: AverageAbilityPointsResult[];
weaponAbilities: AverageAbilityPointsResult[];
}) {
const allBuildsCount = toBuildsCount(allAbilities);
const weaponBuildsCount = toBuildsCount(weaponAbilities);
invariant(
allBuildsCount > weaponBuildsCount,
"allBuildsCount < weaponBuildsCount"
);
const mainOnlyAbilities = abilities
.filter((ability) => ability.type !== "STACKABLE")
.map((ability) => {
const allBuildsAbilityPoints = toAbilityPoints(
allAbilities,
ability.name
);
const weaponBuildsAbilityPoints = toAbilityPoints(
weaponAbilities,
ability.name
);
// main only abilities always take place of 10AP
const allBuildsWithTheAbility = allBuildsAbilityPoints / 10;
const weaponBuildsWithTheAbility = weaponBuildsAbilityPoints / 10;
return {
name: ability.name,
percentage: {
all: roundToNDecimalPlaces(
(allBuildsWithTheAbility / allBuildsCount) * 100
),
weapon: roundToNDecimalPlaces(
(weaponBuildsWithTheAbility / weaponBuildsCount) * 100
),
},
};
})
.sort((a, b) => b.percentage.weapon - a.percentage.weapon);
const stackableAbilities = abilities
.filter((ability) => ability.type === "STACKABLE")
.map((ability) => {
const allBuildsAbilityPoints = toAbilityPoints(
allAbilities,
ability.name
);
const weaponBuildsAbilityPoints = toAbilityPoints(
weaponAbilities,
ability.name
);
return {
name: ability.name,
apAverage: {
all: roundToNDecimalPlaces(allBuildsAbilityPoints / allBuildsCount),
weapon: roundToNDecimalPlaces(
weaponBuildsAbilityPoints / weaponBuildsCount
),
},
};
})
.sort((a, b) => b.apAverage.weapon - a.apAverage.weapon);
return {
mainOnlyAbilities,
stackableAbilities,
weaponBuildsCount,
};
}
// ---
type AbilityCountsMap = Map<Ability, number>;
const POPULAR_BUILDS_TO_SHOW = 25;
export function popularBuilds(builds: Array<AbilitiesByWeapon>) {
const counts = new Map<string, number>();
for (const build of builds) {
const summedUpAbilities = sumUpAbilities(build);
const serializedAbilities = serializeAbilityCountsMap(summedUpAbilities);
counts.set(serializedAbilities, (counts.get(serializedAbilities) ?? 0) + 1);
}
const serializedToShow = Array.from(counts.entries())
.sort((a, b) => b[1] - a[1])
.filter(([, count]) => count > 1)
.slice(0, POPULAR_BUILDS_TO_SHOW);
return serializedToShowToResultType(serializedToShow);
}
function sumUpAbilities(build: AbilitiesByWeapon) {
const result: AbilityCountsMap = new Map();
for (const { ability, abilityPoints } of build.abilities) {
result.set(ability, (result.get(ability) ?? 0) + abilityPoints);
}
return result;
}
function serializeAbilityCountsMap(abilityCountsMap: AbilityCountsMap) {
return Array.from(abilityCountsMap.entries())
.sort((a, b) => {
if (a[1] === b[1]) {
return a[0].localeCompare(b[0]);
}
return b[1] - a[1];
})
.map(([ability, count]) => `${ability}_${count}`)
.join(",");
}
function serializedToShowToResultType(serializedToShow: [string, number][]) {
let previousCount: number;
return serializedToShow.map(([serialized, count]) => {
const abilities = serialized.split(",").map((serializedAbility) => {
const [ability, count] = serializedAbility.split("_");
invariant(ability, "ability is not defined");
invariant(count, "count is not defined");
return {
ability: ability as Ability,
count: isStackableAbility(ability as Ability)
? Number(count)
: undefined,
};
});
if (previousCount === count) {
return { abilities, count: null, id: serialized };
}
previousCount = count;
return { abilities, count, id: serialized };
});
}

View File

@ -0,0 +1,24 @@
.build-stats__ability-row {
display: flex;
align-items: center;
gap: var(--s-3);
}
.build-stats__bars {
font-size: var(--fonts-sm);
font-weight: var(--semi-bold);
display: grid;
align-items: center;
grid-template-columns: max-content 65px 1fr;
column-gap: var(--s-2);
row-gap: var(--s-1);
}
.build-stats__bar {
background-color: var(--theme);
height: 100%;
}
.build-stats__value-text {
width: 100px;
}

View File

@ -0,0 +1,33 @@
import { sql } from "~/db/sql";
import type { Ability, MainWeaponId } from "~/modules/in-game-lists";
const stm = sql.prepare(/* sql */ `
select
json_group_array(
json_object(
'ability',
"BuildAbility"."ability",
'abilityPoints',
"BuildAbility"."abilityPoints"
)
) as "abilities"
from "BuildAbility"
left join "BuildWeapon" on "BuildWeapon"."buildId" = "BuildAbility"."buildId"
where "BuildWeapon"."weaponSplId" = @weaponSplId
group by "BuildAbility"."buildId"
`);
export interface AbilitiesByWeapon {
abilities: Array<{
ability: Ability;
abilityPoints: number;
}>;
}
export function abilitiesByWeaponId(
weaponSplId: MainWeaponId
): Array<AbilitiesByWeapon> {
return stm
.all({ weaponSplId })
.map((row) => ({ abilities: JSON.parse(row.abilities) }));
}

View File

@ -0,0 +1,30 @@
import { sql } from "~/db/sql";
import type { Ability, MainWeaponId } from "~/modules/in-game-lists";
const query = (includeWeaponId: boolean) => /* sql */ `
select "BuildAbility"."ability", sum("BuildAbility"."abilityPoints") as "abilityPointsSum"
from "BuildAbility"
left join "BuildWeapon" on "BuildAbility"."buildId" = "BuildWeapon"."buildId"
${
includeWeaponId
? /* sql */ `where "BuildWeapon"."weaponSplId" = @weaponSplId`
: ""
}
group by "BuildAbility"."ability"
`;
const findByWeaponIdStm = sql.prepare(query(true));
const findAllStm = sql.prepare(query(false));
export interface AverageAbilityPointsResult {
ability: Ability;
abilityPointsSum: number;
}
export function averageAbilityPoints(
weaponSplId?: MainWeaponId | null
): Array<AverageAbilityPointsResult> {
const stm = typeof weaponSplId === "number" ? findByWeaponIdStm : findAllStm;
return stm.all({ weaponSplId });
}

View File

@ -0,0 +1,125 @@
import type { LoaderArgs, MetaFunction, SerializeFrom } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import { Ability } from "~/components/Ability";
import { Main } from "~/components/Main";
import { useTranslation } from "~/hooks/useTranslation";
import { i18next } from "~/modules/i18n";
import { notFoundIfFalsy, type SendouRouteHandle } from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import { weaponNameSlugToId } from "~/utils/unslugify.server";
import {
BUILDS_PAGE,
navIconUrl,
outlinedMainWeaponImageUrl,
weaponBuildPage,
} from "~/utils/urls";
import { popularBuilds } from "../build-stats-utils";
import { abilitiesByWeaponId } from "../queries/abilitiesByWeaponId.server";
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
if (!data) return {};
return {
title: data.meta.title,
};
};
export const handle: SendouRouteHandle = {
i18n: ["analyzer", "builds"],
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
if (!data) return [];
return [
{
imgPath: navIconUrl("builds"),
href: BUILDS_PAGE,
type: "IMAGE",
},
{
imgPath: outlinedMainWeaponImageUrl(data.meta.weaponId),
href: weaponBuildPage(data.meta.slug),
type: "IMAGE",
},
{
href: "/",
text: data.meta.breadcrumbText,
type: "TEXT",
},
];
},
};
export const loader = async ({ params, request }: LoaderArgs) => {
const t = await i18next.getFixedT(request, ["builds", "weapons", "common"]);
const slug = params["slug"];
const weaponId = notFoundIfFalsy(weaponNameSlugToId(slug));
const abilities = abilitiesByWeaponId(weaponId);
const weaponName = t(`weapons:MAIN_${weaponId}`);
return {
popularBuilds: popularBuilds(abilities),
meta: {
weaponId,
slug: slug!,
title: makeTitle([
t("builds:linkButton.popularBuilds"),
weaponName,
t("common:pages.builds"),
]),
breadcrumbText: t("builds:linkButton.popularBuilds"),
},
};
};
export default function PopularBuildsPage() {
const { t } = useTranslation(["analyzer", "builds"]);
const data = useLoaderData<typeof loader>();
return (
<Main className="stack lg">
{data.popularBuilds.length === 0 && (
<div className="text-lg text-lighter text-center">
{t("builds:noPopularBuilds")}
</div>
)}
{data.popularBuilds.map((build, i) => {
return (
<div key={build.id} className="stack horizontal lg items-center">
<div
className={clsx("stack items-center", {
invisible: !build.count,
})}
>
<div className="text-lg text-lighter font-bold">#{i + 1}</div>
<div className="text-sm font-semi-bold text-theme">
×{build.count}
</div>
</div>{" "}
<div className="stack horizontal md flex-wrap">
{build.abilities.map(({ ability, count }) => {
return (
<div
key={ability}
className="text-sm font-semi-bold stack xs items-center"
>
<Ability ability={ability} size="SUB" />{" "}
<div className={clsx({ invisible: !count })}>
{count}
{t("analyzer:abilityPoints.short")}
</div>
</div>
);
})}
</div>
</div>
);
})}
</Main>
);
}

View File

@ -0,0 +1,192 @@
import type {
LinksFunction,
LoaderArgs,
MetaFunction,
SerializeFrom,
} from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { Main } from "~/components/Main";
import { weaponNameSlugToId } from "~/utils/unslugify.server";
import { averageAbilityPoints } from "../queries/averageAbilityPoints.server";
import { abilityPointCountsToAverages } from "../build-stats-utils";
import { Ability } from "~/components/Ability";
import styles from "../build-stats.css";
import { WeaponImage } from "~/components/Image";
import type { SendouRouteHandle } from "~/utils/remix";
import { notFoundIfFalsy } from "~/utils/remix";
import { MAX_AP } from "~/constants";
import { useTranslation } from "~/hooks/useTranslation";
import {
BUILDS_PAGE,
navIconUrl,
outlinedMainWeaponImageUrl,
weaponBuildPage,
} from "~/utils/urls";
import { i18next } from "~/modules/i18n";
import { makeTitle } from "~/utils/strings";
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
if (!data) return {};
return {
title: data.meta.title,
};
};
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export const handle: SendouRouteHandle = {
i18n: ["weapons", "builds", "analyzer"],
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
if (!data) return [];
return [
{
imgPath: navIconUrl("builds"),
href: BUILDS_PAGE,
type: "IMAGE",
},
{
imgPath: outlinedMainWeaponImageUrl(data.weaponId),
href: weaponBuildPage(data.meta.slug),
type: "IMAGE",
},
{
href: "/",
text: data.meta.breadcrumbText,
type: "TEXT",
},
];
},
};
export const loader = async ({ params, request }: LoaderArgs) => {
const t = await i18next.getFixedT(request, ["builds", "weapons", "common"]);
const weaponId = notFoundIfFalsy(weaponNameSlugToId(params["slug"]));
const weaponName = t(`weapons:MAIN_${weaponId}`);
return {
stats: abilityPointCountsToAverages({
allAbilities: averageAbilityPoints(),
weaponAbilities: averageAbilityPoints(weaponId),
}),
weaponId,
meta: {
slug: params["slug"]!,
title: makeTitle([
t("builds:linkButton.abilityStats"),
weaponName,
t("common:pages.builds"),
]),
breadcrumbText: t("builds:linkButton.abilityStats"),
},
};
};
export default function BuildStatsPage() {
const { t } = useTranslation(["weapons", "builds", "analyzer"]);
const data = useLoaderData<typeof loader>();
return (
<Main halfWidth className="stack lg">
<div className="text-xs text-lighter font-bold">
{t("builds:stats.count.title", {
count: data.stats.weaponBuildsCount,
weapon: t(`weapons:MAIN_${data.weaponId}`),
})}
</div>
<div className="stack md">
<h2 className="text-lg">{t("builds:stats.ap.title")}</h2>
<div className="stack md">
{data.stats.stackableAbilities.map((stats) => {
const apToPx = (ap: number) =>
Math.floor(
(ap / data.stats.stackableAbilities[0]!.apAverage.weapon) * 200
);
return (
<div key={stats.name} className="build-stats__ability-row">
<Ability ability={stats.name} size="SUB" />
<div className="build-stats__bars">
<div>
<WeaponImage
variant="badge"
weaponSplId={data.weaponId}
width={22}
/>{" "}
</div>
<div>
{stats.apAverage.weapon} {t("analyzer:abilityPoints.short")}
</div>{" "}
<div
className="build-stats__bar"
style={{ width: `${apToPx(stats.apAverage.weapon)}px` }}
/>
<div className="text-xs text-lighter font-bold justify-self-center">
{t("builds:stats.all")}
</div>
<div>
{stats.apAverage.all} {t("analyzer:abilityPoints.short")}
</div>{" "}
<div
className="build-stats__bar"
style={{ width: `${apToPx(stats.apAverage.all)}px` }}
/>
</div>
</div>
);
})}
</div>
</div>
<div className="stack md">
<h2 className="text-lg">{t("builds:stats.percentage.title")}</h2>
<div className="stack md">
{data.stats.mainOnlyAbilities.map((stats) => {
const percentageToPx = (ap: number) =>
Math.floor((ap / MAX_AP) * 125);
return (
<div key={stats.name} className="build-stats__ability-row">
<Ability ability={stats.name} size="SUB" />
<div className="build-stats__bars">
<div>
<WeaponImage
variant="badge"
weaponSplId={data.weaponId}
width={22}
/>{" "}
</div>
<div>{stats.percentage.weapon}%</div>{" "}
<div
className="build-stats__bar"
style={{
width: `${percentageToPx(stats.percentage.weapon)}px`,
}}
/>
<div className="text-xs text-lighter font-bold justify-self-center">
{t("builds:stats.all")}
</div>
<div>{stats.percentage.all}%</div>{" "}
<div
className="build-stats__bar"
style={{
width: `${percentageToPx(stats.percentage.all)}px`,
}}
/>
</div>
</div>
);
})}
</div>
</div>
</Main>
);
}

View File

@ -73,3 +73,11 @@ export const abilities = [
] as const;
export const abilitiesShort = abilities.map((ability) => ability.name);
export const stackableAbilitiesShort = abilities
.filter((ability) => ability.type === "STACKABLE")
.map((ability) => ability.name);
export const mainOnlyAbilitiesShort = abilities
.filter((ability) => ability.type !== "STACKABLE")
.map((ability) => ability.name);

View File

@ -22,6 +22,8 @@ import {
weaponBuildPage,
} from "~/utils/urls";
import { Main } from "~/components/Main";
import { ChartBarIcon } from "~/components/icons/ChartBar";
import { FireIcon } from "~/components/icons/Fire";
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
@ -90,10 +92,28 @@ export const loader = async ({ request, params }: LoaderArgs) => {
export default function WeaponsBuildsPage() {
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["common"]);
const { t } = useTranslation(["common", "builds"]);
return (
<Main className="stack lg">
<div className="builds-buttons">
<LinkButton
to="stats"
variant="outlined"
icon={<ChartBarIcon />}
size="tiny"
>
{t("builds:linkButton.abilityStats")}
</LinkButton>
<LinkButton
to="popular"
variant="outlined"
icon={<FireIcon />}
size="tiny"
>
{t("builds:linkButton.popularBuilds")}
</LinkButton>
</div>
<div className="builds-container">
{data.builds.map((build) => {
return (

View File

@ -824,6 +824,12 @@ dialog::backdrop {
row-gap: var(--s-4);
}
.builds-buttons {
display: flex;
justify-content: flex-end;
gap: var(--s-2);
}
.build {
position: relative;
display: flex;

View File

@ -42,6 +42,10 @@
color: var(--theme-warning);
}
.text-theme {
color: var(--theme);
}
.fill-success {
fill: var(--theme-success);
}
@ -206,6 +210,10 @@
justify-self: flex-end;
}
.justify-self-center {
justify-self: center;
}
.self-center {
align-self: center;
}

View File

@ -0,0 +1,5 @@
module.exports.up = function (db) {
db.prepare(
/* sql */ `alter table "BuildAbility" add "abilityPoints" integer generated always as (case when "slotIndex" = 0 then 10 else 3 end) virtual`
).run();
};

View File

@ -11,5 +11,14 @@
"forms.gear.CLOTHES": "Gear (clothes)",
"forms.gear.SHOES": "Gear (shoes)",
"deleteConfirm": "Delete build '{{title}}'?"
"deleteConfirm": "Delete build '{{title}}'?",
"stats.count.title": "Stats from {{count}} {{weapon}} builds",
"stats.ap.title": "Ability point averages",
"stats.percentage.title": "Primary-only ability appearance",
"stats.all": "All",
"linkButton.abilityStats": "Ability stats",
"linkButton.popularBuilds": "Popular builds",
"noPopularBuilds": "It seems there is no popular builds for this weapon at this moment."
}

View File

@ -39,6 +39,15 @@ module.exports = {
route("/vods", "features/vods/routes/vods.tsx");
route("/vods/new", "features/vods/routes/vods.new.tsx");
route("/vods/:id", "features/vods/routes/vods.$id.tsx");
route(
"/builds/:slug/stats",
"features/build-stats/routes/builds.$slug.stats.tsx"
);
route(
"/builds/:slug/popular",
"features/build-stats/routes/builds.$slug.popular.tsx"
);
});
},
};