SendouQ favorite weapons (#2286)

* Migrate code to new weapon list type

* Add database migration script

* Run formatter

* Display favorite background image

* Null latest weapon on empty array
This commit is contained in:
hfcRed 2025-05-18 12:34:46 +02:00 committed by GitHub
parent 5d3fa14edf
commit 1aa3425120
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 90 additions and 23 deletions

View File

@ -778,6 +778,11 @@ export interface UserMapModePreferences {
}>;
}
export interface QWeaponPool {
weaponSplId: MainWeaponId;
isFavorite: number;
}
export const BUILD_SORT_IDENTIFIERS = [
"UPDATED_AT",
"TOP_500",
@ -836,7 +841,7 @@ export interface User {
vc: Generated<"YES" | "NO" | "LISTEN_ONLY">;
youtubeId: string | null;
mapModePreferences: JSONColumnTypeNullable<UserMapModePreferences>;
qWeaponPool: ColumnType<MainWeaponId[] | null, string | null, string | null>;
qWeaponPool: JSONColumnTypeNullable<QWeaponPool[]>;
plusSkippedForSeasonNth: number | null;
noScreen: Generated<number>;
buildSorting: JSONColumnTypeNullable<BuildSort[]>;

View File

@ -1,7 +1,11 @@
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { ParsedMemento, Tables, UserSkillDifference } from "~/db/tables";
import type { MainWeaponId } from "~/modules/in-game-lists";
import type {
ParsedMemento,
QWeaponPool,
Tables,
UserSkillDifference,
} from "~/db/tables";
import { COMMON_USER_FIELDS, userChatNameColor } from "~/utils/kysely.server";
export function findById(id: number) {
@ -57,7 +61,7 @@ export interface GroupForMatch {
role: Tables["GroupMember"]["role"];
customUrl: Tables["User"]["customUrl"];
inGameName: Tables["User"]["inGameName"];
weapons: Array<MainWeaponId>;
weapons: Array<QWeaponPool>;
chatNameColor: string | null;
vc: Tables["User"]["vc"];
languages: string[];

View File

@ -1,6 +1,6 @@
import { db } from "~/db/sql";
import type { Tables, UserMapModePreferences } from "~/db/tables";
import { type MainWeaponId, modesShort } from "~/modules/in-game-lists";
import type { QWeaponPool, Tables, UserMapModePreferences } from "~/db/tables";
import { modesShort } from "~/modules/in-game-lists";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
export async function settingsByUserId(userId: number) {
@ -74,7 +74,7 @@ export function updateVoiceChat(args: {
export function updateSendouQWeaponPool(args: {
userId: number;
weaponPool: MainWeaponId[];
weaponPool: QWeaponPool[];
}) {
return db
.updateTable("User")

View File

@ -7,9 +7,9 @@ import {
id,
modeShort,
noDuplicates,
qWeapon,
safeJSONParse,
stageId,
weaponSplId,
} from "~/utils/zod";
import {
AMOUNT_OF_MAPS_IN_POOL_PER_MODE,
@ -67,7 +67,7 @@ export const settingsActionSchema = z.union([
_action: _action("UPDATE_SENDOUQ_WEAPON_POOL"),
weaponPool: z.preprocess(
safeJSONParse,
z.array(weaponSplId).max(SENDOUQ_WEAPON_POOL_MAX_SIZE),
z.array(qWeapon).max(SENDOUQ_WEAPON_POOL_MAX_SIZE),
),
}),
z.object({

View File

@ -17,6 +17,8 @@ import { MapIcon } from "~/components/icons/Map";
import { MicrophoneFilledIcon } from "~/components/icons/MicrophoneFilled";
import { PuzzleIcon } from "~/components/icons/Puzzle";
import { SpeakerFilledIcon } from "~/components/icons/SpeakerFilled";
import { StarIcon } from "~/components/icons/Star";
import { StarFilledIcon } from "~/components/icons/StarFilled";
import { TrashIcon } from "~/components/icons/Trash";
import { UsersIcon } from "~/components/icons/Users";
import type { Preference, Tables, UserMapModePreferences } from "~/db/tables";
@ -383,7 +385,7 @@ function WeaponPool() {
const [weapons, setWeapons] = React.useState(data.settings.qWeaponPool ?? []);
const fetcher = useFetcher();
const latestWeapon = weapons[weapons.length - 1];
const latestWeapon = weapons[weapons.length - 1]?.weaponSplId ?? null;
return (
<details>
@ -408,12 +410,15 @@ function WeaponPool() {
if (!weapon) return;
setWeapons([
...weapons,
Number(weapon.value) as MainWeaponId,
{
weaponSplId: Number(weapon.value) as MainWeaponId,
isFavorite: 0,
},
]);
}}
// empty on selection
key={latestWeapon ?? "empty"}
weaponIdsToOmit={new Set(weapons)}
weaponIdsToOmit={new Set(weapons.map((w) => w.weaponSplId))}
fullWidth
/>
</div>
@ -426,23 +431,45 @@ function WeaponPool() {
<div className="stack horizontal sm justify-center">
{weapons.map((weapon) => {
return (
<div key={weapon} className="stack xs">
<div key={weapon.weaponSplId} className="stack xs">
<div>
<WeaponImage
weaponSplId={weapon}
variant="badge"
weaponSplId={weapon.weaponSplId}
variant={weapon.isFavorite ? "badge-5-star" : "badge"}
width={38}
height={38}
/>
</div>
<div className="stack sm horizontal items-center justify-center">
<Button
icon={weapon.isFavorite ? <StarFilledIcon /> : <StarIcon />}
variant="minimal"
aria-label="Favorite weapon"
onClick={() =>
setWeapons(
weapons.map((w) =>
w.weaponSplId === weapon.weaponSplId
? {
...weapon,
isFavorite: weapon.isFavorite === 1 ? 0 : 1,
}
: w,
),
)
}
/>
<Button
icon={<TrashIcon />}
variant="minimal-destructive"
aria-label="Delete weapon"
onClick={() =>
setWeapons(weapons.filter((w) => w !== weapon))
setWeapons(
weapons.filter(
(w) => w.weaponSplId !== weapon.weaponSplId,
),
)
}
testId={`delete-weapon-${weapon.weaponSplId}`}
size="tiny"
/>
</div>

View File

@ -374,9 +374,9 @@ function GroupMember({
{member.weapons?.map((weapon) => {
return (
<WeaponImage
key={weapon}
weaponSplId={weapon}
variant="badge"
key={weapon.weaponSplId}
weaponSplId={weapon.weaponSplId}
variant={weapon.isFavorite ? "badge-5-star" : "badge"}
size={26}
/>
);

View File

@ -1,5 +1,5 @@
import type { ParsedMemento, Tables } from "~/db/tables";
import type { MainWeaponId, ModeShort } from "~/modules/in-game-lists";
import type { ParsedMemento, QWeaponPool, Tables } from "~/db/tables";
import type { ModeShort } from "~/modules/in-game-lists";
import type { TieredSkill } from "../mmr/tiered.server";
import type { GroupForMatch } from "../sendouq-match/QMatchRepository.server";
@ -31,7 +31,7 @@ export type LookingGroup = {
plusTier?: Tables["PlusTier"]["tier"];
role: Tables["GroupMember"]["role"];
note?: Tables["GroupMember"]["note"];
weapons?: MainWeaponId[];
weapons?: QWeaponPool[];
skill?: TieredSkill | "CALCULATING";
vc?: Tables["User"]["vc"];
inGameName?: Tables["User"]["inGameName"];

View File

@ -100,6 +100,11 @@ export const weaponSplId = z.preprocess(
numericEnum(mainWeaponIds),
);
export const qWeapon = z.object({
weaponSplId,
isFavorite: z.union([z.literal(0), z.literal(1)]),
});
export const modeShort = z.enum(["TW", "SZ", "TC", "RM", "CB"]);
export const stageId = z.preprocess(actualNumber, numericEnum(stageIds));
@ -151,7 +156,10 @@ export const safeStringSchema = ({ min, max }: { min?: number; max: number }) =>
export const safeNullableStringSchema = ({
min,
max,
}: { min?: number; max: number }) =>
}: {
min?: number;
max: number;
}) =>
z.preprocess(
actuallyNonEmptyStringOrNull,
z

View File

@ -0,0 +1,23 @@
export function up(db) {
db.transaction(() => {
const users = db
.prepare(/* sql */ "SELECT id, qWeaponPool FROM User")
.all();
const updateStatement = db.prepare(
/* sql */ "UPDATE User SET qWeaponPool = ? WHERE id = ?",
);
for (const user of users) {
if (!user.qWeaponPool) continue;
const pool = JSON.parse(user.qWeaponPool);
const newPool = pool.map((id) => ({
weaponSplId: id,
isFavorite: 0,
}));
updateStatement.run(JSON.stringify(newPool), user.id);
}
})();
}