Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import * as LFGPostFactory from "~/db/seed/factories/LFGPostFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as LFGRepository from "./LFGRepository.server";
describe("LFGRepository.findAllPosts plus-tier visibility", () => {
test("still returns the author's own post after they lose their plus tier", async () => {
const author = await UserFactory.create(null, { plusTier: 2 });
const { id: postId } = await LFGPostFactory.create({
type: "PLAYER_FOR_TEAM",
authorId: author.id,
plusTierVisibility: 2,
});
// while +2, the author can of course see (and thus manage) their own post
const whileMember = await LFGRepository.findAllPosts({
id: author.id,
plusTier: 2,
});
expect(whileMember.map((post) => post.id)).toContain(postId);
// next monthly voting drops the author from the plus server: plusTier -> null.
// Their own post must remain visible to them, otherwise DELETE_POST / BUMP_POST
// (which resolve the post through findAllPosts) 404 and it can never be taken down.
const afterDrop = await LFGRepository.findAllPosts({
id: author.id,
plusTier: null,
});
expect(afterDrop.map((post) => post.id)).toContain(postId);
});
});

View File

@@ -0,0 +1,171 @@
import { sub } from "date-fns";
import { type NotNull, sql, type Transaction } from "kysely";
import { db } from "~/db/sql";
import type { DB, TablesInsertable } from "~/db/tables";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import {
commonUserSelect,
concatUserSubmittedImagePrefix,
jsonArrayFrom,
jsonObjectFrom,
userProfileWeapons,
} from "~/utils/kysely.server";
import { LFG } from "./lfg-constants";
export async function findAllPosts(user?: {
id: number;
plusTier: number | null;
}) {
// "-1" won't match any user
const userId = user?.id ?? -1;
const rows = await db
.selectFrom("LFGPost")
.select(({ eb }) => [
"LFGPost.id",
"LFGPost.timezone",
"LFGPost.type",
"LFGPost.text",
"LFGPost.createdAt",
"LFGPost.updatedAt",
"LFGPost.plusTierVisibility",
"LFGPost.languages",
jsonObjectFrom(
eb
.selectFrom("User")
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.select(({ eb: innerEb }) => [
...commonUserSelect(innerEb),
"User.languages",
"User.country",
"PlusTier.tier as plusTier",
userProfileWeapons(innerEb).as("weaponPool"),
])
.whereRef("User.id", "=", "LFGPost.authorId"),
).as("author"),
jsonObjectFrom(
eb
.selectFrom("Team")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"Team.avatarImgId",
)
.select(({ eb: innerEb }) => [
"Team.id",
"Team.name",
concatUserSubmittedImagePrefix(
innerEb.ref("UserSubmittedImage.url"),
).as("avatarUrl"),
jsonArrayFrom(
innerEb
.selectFrom("TeamMemberWithSecondary")
.innerJoin("User", "User.id", "TeamMemberWithSecondary.userId")
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.select(({ eb: innestEb }) => [
...commonUserSelect(innestEb),
"User.languages",
"User.country",
"PlusTier.tier as plusTier",
userProfileWeapons(innestEb).as("weaponPool"),
])
.whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id"),
).as("members"),
])
.whereRef("Team.id", "=", "LFGPost.teamId"),
).as("team"),
])
.orderBy(sql`LFGPost.authorId = ${sql`${userId}`} desc`)
.orderBy("LFGPost.updatedAt", "desc")
.orderBy("LFGPost.type", "asc")
.where((eb) =>
eb.or([
eb(
"LFGPost.updatedAt",
">",
dateToDatabaseTimestamp(postExpiryCutoff()),
),
eb("LFGPost.authorId", "=", userId),
]),
)
.$narrowType<{ author: NotNull }>()
.execute();
return rows
.filter((row) => {
if (!row.plusTierVisibility) return true;
if (row.author.id === userId) return true;
if (!user?.plusTier) return false;
return row.plusTierVisibility >= user.plusTier;
})
.map((row) => ({
...row,
permissions: {
EDIT: [row.author.id],
DELETE: [row.author.id],
},
}));
}
const postExpiryCutoff = () =>
sub(new Date(), { days: LFG.POST_FRESHNESS_DAYS });
export function insertPost(
args: Omit<TablesInsertable["LFGPost"], "updatedAt">,
) {
return db
.insertInto("LFGPost")
.values(args)
.returning("id")
.executeTakeFirstOrThrow();
}
export function updatePost(
postId: number,
args: Omit<TablesInsertable["LFGPost"], "updatedAt" | "authorId">,
) {
return db
.updateTable("LFGPost")
.set({
teamId: args.teamId,
text: args.text,
timezone: args.timezone,
type: args.type,
plusTierVisibility: args.plusTierVisibility,
languages: args.languages,
updatedAt: dateToDatabaseTimestamp(new Date()),
})
.where("id", "=", postId)
.execute();
}
export function bumpPost(postId: number) {
return db
.updateTable("LFGPost")
.set({
updatedAt: databaseTimestampNow(),
})
.where("id", "=", postId)
.execute();
}
export function deletePost(id: number) {
return db.deleteFrom("LFGPost").where("id", "=", id).execute();
}
export function deletePostsByTeamId(teamId: number, trx?: Transaction<DB>) {
return (trx ?? db)
.deleteFrom("LFGPost")
.where("teamId", "=", teamId)
.execute();
}
export async function findByAuthorUserId(authorId: number) {
return db
.selectFrom("LFGPost")
.select(["id", "type"])
.where("authorId", "=", authorId)
.orderBy("updatedAt", "desc")
.execute();
}

View File

@@ -0,0 +1,85 @@
import type { ActionFunctionArgs } from "react-router";
import { redirect } from "react-router";
import type { Tables } from "~/db/tables";
import { requireUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseFormData } from "~/form/parse.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import { errorToastIfFalsy } from "~/utils/remix.server";
import { LFG_PAGE } from "~/utils/urls";
import * as LFGRepository from "../LFGRepository.server";
import { TEAM_POST_TYPES } from "../lfg-constants";
import { lfgNewSchema } from "../lfg-schemas";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = requireUser();
const result = await parseFormData({
request,
schema: lfgNewSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
const type = data.type as Tables["LFGPost"]["type"];
const identifier = String(user.id);
const { team } =
(await UserRepository.findProfileByIdentifier(identifier)) ?? {};
const shouldIncludeTeam = TEAM_POST_TYPES.includes(type);
errorToastIfFalsy(
!shouldIncludeTeam || team,
"Team needs to be set for this type of post",
);
const plusTierVisibility = data.plusTierVisibility
? Number(data.plusTierVisibility)
: null;
if (data.postId) {
await validateCanUpdatePost({
postId: data.postId,
user,
});
await LFGRepository.updatePost(data.postId, {
text: data.postText,
timezone: data.timezone,
type,
teamId: shouldIncludeTeam ? team?.id : null,
plusTierVisibility,
languages:
data.languages.length > 0 ? JSON.stringify(data.languages) : null,
});
} else {
await LFGRepository.insertPost({
text: data.postText,
timezone: data.timezone,
type,
teamId: shouldIncludeTeam ? team?.id : null,
authorId: user.id,
plusTierVisibility,
languages:
data.languages.length > 0 ? JSON.stringify(data.languages) : null,
});
}
return redirect(LFG_PAGE);
};
const validateCanUpdatePost = async ({
postId,
user,
}: {
postId: number;
user: { id: number; plusTier: number | null };
}) => {
const posts = await LFGRepository.findAllPosts(user);
const post = posts.find((post) => post.id === postId);
errorToastIfFalsy(post, "Post to update not found");
requirePermission(post, "EDIT");
};

View File

@@ -0,0 +1,33 @@
import type { ActionFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import * as LFGRepository from "../LFGRepository.server";
import { lfgActionSchema } from "../lfg-schemas";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = requireUser();
const data = await parseRequestPayload({
request,
schema: lfgActionSchema,
});
const posts = await LFGRepository.findAllPosts(user);
const post = posts.find((post) => post.id === data.id);
errorToastIfFalsy(post, "Post not found");
switch (data._action) {
case "DELETE_POST": {
requirePermission(post, "DELETE");
await LFGRepository.deletePost(data.id);
break;
}
case "BUMP_POST": {
requirePermission(post, "EDIT");
await LFGRepository.bumpPost(data.id);
break;
}
}
return null;
};

View File

@@ -0,0 +1,119 @@
.wideLayout {
display: grid;
grid-template-columns: 1fr;
align-items: flex-start;
column-gap: var(--s-6);
row-gap: var(--s-2);
& .leftRow {
display: flex;
gap: var(--s-2);
flex-direction: column;
}
}
@container (width >= 640px) {
.wideLayout {
grid-template-columns: 1fr 2fr;
row-gap: 0;
}
.wideLayout .leftRow {
position: sticky;
top: 55px;
}
}
.userName {
max-width: 125px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
color: var(--color-text);
}
.teamMemberName {
max-width: 75px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--color-text);
}
.textContainer {
position: relative;
max-height: 150px;
overflow: hidden;
}
.textContainerExpanded {
position: relative;
margin-bottom: var(--s-6);
}
.text {
word-wrap: break-word;
white-space: pre-wrap;
word-break: break-word;
}
.textCut {
background-image: linear-gradient(to bottom, transparent, var(--color-bg));
width: 100%;
position: absolute;
top: 0;
height: 100%;
}
.showAllButton {
position: absolute;
bottom: 0;
left: 0;
right: 0;
width: max-content;
margin-left: auto;
margin-right: auto;
z-index: 1;
}
.showAllButtonExpanded {
bottom: -35px;
}
.pill {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
border-radius: var(--radius-selector);
background-color: var(--color-bg-high);
padding: 0 var(--s-2);
height: var(--selector-size-sm);
display: flex;
align-items: center;
gap: var(--s-0-5);
position: relative;
}
.pillPlaceholder {
visibility: hidden;
width: 37px;
}
.editButton {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
border-radius: var(--radius-selector);
background-color: var(--color-bg-higher);
padding: 0 var(--s-2);
height: var(--selector-size-sm);
color: var(--color-text);
display: flex;
align-items: center;
gap: var(--s-1);
& > svg {
width: 14px;
height: 14px;
}
}

View File

@@ -0,0 +1,409 @@
import clsx from "clsx";
import { SquarePen, Trash } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Link, useFetcher } from "react-router";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { SendouButton } from "~/components/elements/Button";
import { Flag } from "~/components/Flag";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { WeaponImage } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import { NoteAvatar } from "~/components/NoteAvatar";
import { lfgNewPostPage } from "~/features/lfg/lfg-urls";
import {
UserCard,
useUserCardData,
} from "~/features/user-card/components/UserCard";
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
import { useHydrated } from "~/hooks/useHydrated";
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
import { useHasPermission } from "~/modules/permissions/hooks";
import { databaseTimestampToDate } from "~/utils/dates";
import { hourDifferenceBetweenTimezones } from "../core/timezone";
import type { LFGLoaderData } from "../routes/lfg";
import styles from "./LFGPost.module.css";
type Post = LFGLoaderData["posts"][number];
export function LFGPost({ post }: { post: Post }) {
if (post.team) {
return <TeamLFGPost post={{ ...post, team: post.team }} />;
}
return <UserLFGPost post={post} />;
}
const USER_POST_EXPANDABLE_CRITERIA = 300;
function UserLFGPost({ post }: { post: Post }) {
const canEdit = useHasPermission(post, "EDIT");
const canDelete = useHasPermission(post, "DELETE");
const [isExpanded, setIsExpanded] = React.useState(false);
return (
<div className={styles.wideLayout}>
<div className={styles.leftRow}>
<PostUserHeader
author={post.author}
includeWeapons={post.type !== "COACH_FOR_TEAM"}
/>
<PostTime createdAt={post.createdAt} updatedAt={post.updatedAt} />
<PostPills
languages={post.languages}
timezone={post.timezone}
canEdit={canEdit}
postId={post.id}
/>
</div>
<div>
<div className="stack horizontal justify-between items-center">
<PostTextTypeHeader type={post.type} />
{canDelete ? (
<PostDeleteButton id={post.id} type={post.type} />
) : null}
</div>
<PostExpandableText
text={post.text}
isExpanded={isExpanded}
setIsExpanded={setIsExpanded}
expandableCriteria={USER_POST_EXPANDABLE_CRITERIA}
/>
</div>
</div>
);
}
function TeamLFGPost({
post,
}: {
post: Post & { team: NonNullable<Post["team"]> };
}) {
const isHydrated = useHydrated();
const canEdit = useHasPermission(post, "EDIT");
const canDelete = useHasPermission(post, "DELETE");
const [isExpanded, setIsExpanded] = React.useState(false);
return (
<div className={styles.wideLayout}>
<div className="stack md">
<div className="stack xs">
<div className="stack horizontal items-center justify-between">
<PostTeamLogoHeader team={post.team} />
<div className="stack horizontal items-center sm">
{isHydrated && <PostTimezonePill timezone={post.timezone} />}
{post.languages ? (
<PostLanguagePill languages={post.languages} />
) : null}
</div>
</div>
<Divider />
<div className="stack horizontal justify-between items-center">
<PostTime createdAt={post.createdAt} updatedAt={post.updatedAt} />
{canEdit ? <PostEditButton id={post.id} /> : null}
</div>
</div>
{isExpanded ? (
<PostTeamMembersFull team={post.team} />
) : (
<PostTeamMembersPeek team={post.team} />
)}
</div>
<div>
<div className="stack horizontal justify-between">
<PostTextTypeHeader type={post.type} />
{canDelete ? (
<PostDeleteButton id={post.id} type={post.type} />
) : null}
</div>
<PostExpandableText
text={post.text}
isExpanded={isExpanded}
setIsExpanded={setIsExpanded}
/>
</div>
</div>
);
}
function PostTeamLogoHeader({ team }: { team: NonNullable<Post["team"]> }) {
return (
<div className="stack horizontal sm items-center font-bold">
{team.avatarUrl ? <Avatar size="xs" url={team.avatarUrl} /> : null}
{team.name}
</div>
);
}
function PostTeamMembersPeek({ team }: { team: NonNullable<Post["team"]> }) {
return (
<div className="stack sm xs-row horizontal flex-wrap">
{team.members.map((member) => (
<PostTeamMember key={member.id} member={member} />
))}
</div>
);
}
function PostTeamMembersFull({ team }: { team: NonNullable<Post["team"]> }) {
return (
<div className="stack lg">
{team.members.map((member) => (
<PostUserHeader key={member.id} author={member} includeWeapons />
))}
</div>
);
}
function PostTeamMember({
member,
}: {
member: NonNullable<Post["team"]>["members"][number];
}) {
const cardData = useUserCardData(member.id);
return (
<div className="stack sm items-center flex-same-size">
<UserCard userId={member.id} withMutualFriends>
<span className="stack sm items-center">
<NoteAvatar sentiment={cardData?.privateNote?.sentiment} size="sm">
<Avatar size="xs" user={member} />
</NoteAvatar>
<span className={styles.teamMemberName}>{member.username}</span>
</span>
</UserCard>
</div>
);
}
function PostUserHeader({
author,
includeWeapons,
}: {
author: Post["author"];
includeWeapons: boolean;
}) {
const cardData = useUserCardData(author.id);
return (
<div className="stack sm">
<div className="stack sm horizontal items-center">
<div className="stack horizontal sm items-center text-md font-bold">
<UserCard userId={author.id} withMutualFriends>
<span className="stack sm horizontal items-center">
<NoteAvatar
sentiment={cardData?.privateNote?.sentiment}
size="md"
>
<Avatar size="xsm" user={author} />
</NoteAvatar>
<span className={styles.userName}>{author.username}</span>
</span>
</UserCard>{" "}
{author.country ? <Flag countryCode={author.country} tiny /> : null}
</div>
</div>
{includeWeapons ? (
<div className="stack horizontal sm">
{author.weaponPool.map((weapon) => (
<WeaponImage key={weapon.weaponSplId} weapon={weapon} size={32} />
))}
</div>
) : null}
</div>
);
}
function PostTime({
createdAt,
updatedAt,
}: {
createdAt: number;
updatedAt: number;
}) {
const { t } = useTranslation(["lfg"]);
const formatDistanceToNow = useFormatDistanceToNow();
const createdAtDate = databaseTimestampToDate(createdAt);
const updatedAtDate = databaseTimestampToDate(updatedAt);
const overDayDifferenceBetween =
updatedAtDate.getTime() - createdAtDate.getTime() > 1000 * 60 * 60 * 24;
return (
<div className="text-lighter text-xs font-bold">
<LocaleTime
date={createdAtDate}
options={{
month: "numeric",
day: "numeric",
}}
/>{" "}
{overDayDifferenceBetween ? (
<div className="text-xxs">
<i>
({t("lfg:post.lastActive")}{" "}
{formatDistanceToNow(updatedAtDate, {
addSuffix: true,
})}
)
</i>
</div>
) : null}
</div>
);
}
function PostPills({
timezone,
languages,
canEdit,
postId,
}: {
timezone?: string | null;
languages?: UnifiedLanguageCode[] | null;
canEdit?: boolean;
postId: number;
}) {
const isHydrated = useHydrated();
return (
<div
className={clsx("stack sm xs-row horizontal flex-wrap", {
invisible: !isHydrated,
})}
>
{typeof timezone === "string" && isHydrated && (
<PostTimezonePill timezone={timezone} />
)}
{!isHydrated && <PostTimezonePillPlaceholder />}
{languages ? <PostLanguagePill languages={languages} /> : null}
{canEdit && <PostEditButton id={postId} />}
</div>
);
}
function PostTimezonePillPlaceholder() {
return <div className={clsx(styles.pill, styles.pillPlaceholder)} />;
}
function PostTimezonePill({ timezone }: { timezone: string }) {
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const diff = hourDifferenceBetweenTimezones(userTimezone, timezone);
const textColorClass = () => {
const absDiff = Math.abs(diff);
if (absDiff <= 3) {
return "text-success";
}
if (absDiff <= 6) {
return "text-warning";
}
return "text-error";
};
return (
<div title={timezone} className={clsx(styles.pill, textColorClass())}>
{diff === 0 ? "±" : ""}
{diff > 0 ? "+" : ""}
{diff}h
</div>
);
}
function PostLanguagePill({ languages }: { languages: UnifiedLanguageCode[] }) {
return (
<div className={styles.pill}>{languages.join(" / ").toUpperCase()}</div>
);
}
function PostTextTypeHeader({ type }: { type: Post["type"] }) {
const { t } = useTranslation(["lfg"]);
return (
<div className="text-xs text-lighter font-bold">
{t(`lfg:types.${type}`)}
</div>
);
}
function PostEditButton({ id }: { id: number }) {
const { t } = useTranslation(["common"]);
return (
<Link className={styles.editButton} to={lfgNewPostPage(id)}>
<SquarePen />
{t("common:actions.edit")}
</Link>
);
}
function PostDeleteButton({ id, type }: { id: number; type: Post["type"] }) {
const fetcher = useFetcher();
const { t } = useTranslation(["common", "lfg"]);
return (
<FormWithConfirm
dialogHeading={`Delete post (${(t(`lfg:types.${type}`) as any).toLowerCase()})?`}
fields={[
["id", id],
["_action", "DELETE_POST"],
]}
fetcher={fetcher}
>
<SendouButton
className="small-text"
variant="minimal-destructive"
size="small"
type="submit"
icon={<Trash />}
>
{t("common:actions.delete")}
</SendouButton>
</FormWithConfirm>
);
}
function PostExpandableText({
text,
isExpanded: _isExpanded,
setIsExpanded,
expandableCriteria,
}: {
text: string;
isExpanded: boolean;
setIsExpanded: (isExpanded: boolean) => void;
expandableCriteria?: number;
}) {
const { t } = useTranslation(["common"]);
const isExpandable = !expandableCriteria || text.length > expandableCriteria;
const isExpanded = !isExpandable ? true : _isExpanded;
return (
<div
className={clsx({
[styles.textContainer]: !isExpanded,
[styles.textContainerExpanded]: isExpanded,
})}
>
<div className={styles.text}>{text}</div>
{isExpandable ? (
<SendouButton
onPress={() => setIsExpanded(!isExpanded)}
className={clsx([styles.showAllButton], {
[styles.showAllButtonExpanded]: isExpanded,
})}
variant="outlined"
size="small"
>
{isExpanded
? t("common:actions.showLess")
: t("common:actions.showMore")}
</SendouButton>
) : null}
{!isExpanded ? <div className={styles.textCut} /> : null}
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, test } from "vitest";
import type { LFGFilterValues } from "../lfg-types";
import { type FilterablePost, filterPosts } from "./filtering";
const postOfType = (type: FilterablePost["type"]) =>
({
type,
author: { weaponPool: [] },
team: null,
}) as unknown as FilterablePost;
const noFilters: LFGFilterValues = {
weapons: [],
type: null,
timezone: null,
language: null,
plusTier: null,
minTier: null,
maxTier: null,
};
describe("filterPosts", () => {
test("no weapons selected shows every post", () => {
const posts = [postOfType("PLAYER_FOR_TEAM"), postOfType("COACH_FOR_TEAM")];
const filtered = filterPosts(posts, noFilters, {
tiersMap: new Map(),
viewerTimezone: null,
});
expect(filtered).toHaveLength(2);
});
describe("timezone filter", () => {
test("shows a post from across the date line when local clock times match", () => {
// Pacific/Kiritimati (UTC+14) and Pacific/Honolulu (UTC-10) share the
// exact same local clock time year round
const post = {
...postOfType("PLAYER_FOR_TEAM"),
timezone: "Pacific/Kiritimati",
};
const filtered = filterPosts(
[post],
{ ...noFilters, timezone: 3 },
{
tiersMap: new Map(),
viewerTimezone: "Pacific/Honolulu",
},
);
expect(filtered).toHaveLength(1);
});
test("skips the timezone filter when the viewer's timezone is unknown", () => {
const post = {
...postOfType("PLAYER_FOR_TEAM"),
timezone: "Asia/Tokyo",
};
const filtered = filterPosts(
[post],
{ ...noFilters, timezone: 0 },
{
tiersMap: new Map(),
viewerTimezone: null,
},
);
expect(filtered).toHaveLength(1);
});
});
});

View File

@@ -0,0 +1,186 @@
import type { TierName } from "~/features/mmr/mmr-constants";
import { compareTwoTiers } from "~/features/mmr/mmr-utils";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import {
mainWeaponIds,
weaponIdToBaseWeaponId,
} from "~/modules/in-game-lists/weapon-ids";
import type { Unpacked } from "~/utils/types";
import type * as LFGRepository from "../LFGRepository.server";
import type { LFGFilterValues } from "../lfg-types";
import { createTimezoneHourDifference } from "./timezone";
export type FilterablePost = Unpacked<
Awaited<ReturnType<typeof LFGRepository.findAllPosts>>
>;
export type TiersMap = Map<
number,
{ latest?: TieredSkill["tier"]; previous?: TieredSkill["tier"] }
>;
export interface FilterContext {
tiersMap: TiersMap;
/** `null` when the viewer's timezone is unknown, which disables the timezone filter. */
viewerTimezone: string | null;
}
interface PostFilterContext extends FilterContext {
hourDifference: ReturnType<typeof createTimezoneHourDifference>;
}
export function filterPosts(
posts: Array<FilterablePost>,
filters: LFGFilterValues,
context: FilterContext,
) {
const hourDifference = createTimezoneHourDifference();
return posts.filter((post) =>
postMatchesFilters(post, filters, { ...context, hourDifference }),
);
}
function postMatchesFilters(
post: FilterablePost,
filters: LFGFilterValues,
context: PostFilterContext,
) {
if (
post.type === "COACH_FOR_TEAM" &&
// not visible in the UI
(filters.weapons.length > 0 ||
filters.minTier !== null ||
filters.maxTier !== null)
) {
return false;
}
if (filters.weapons.length > 0 && !matchesWeapons(post, filters.weapons)) {
return false;
}
if (filters.type !== null && post.type !== filters.type) return false;
if (
filters.timezone !== null &&
!matchesTimezone(post, filters.timezone, context)
) {
return false;
}
if (
filters.language !== null &&
!post.languages?.includes(filters.language)
) {
return false;
}
if (filters.plusTier !== null && !matchesPlusTier(post, filters.plusTier)) {
return false;
}
if (
filters.maxTier !== null &&
!matchesMaxTier(post, filters.maxTier, context.tiersMap)
) {
return false;
}
if (
filters.minTier !== null &&
!matchesMinTier(post, filters.minTier, context.tiersMap)
) {
return false;
}
return true;
}
function matchesWeapons(post: FilterablePost, weapons: MainWeaponId[]) {
const weaponIdsWithRelated = weapons.flatMap(weaponIdToRelated);
return checkMatchesSomeUserInPost(post, (user) =>
user.weaponPool.some(({ weaponSplId }) =>
weaponIdsWithRelated.includes(weaponSplId),
),
);
}
function matchesTimezone(
post: FilterablePost,
maxHourDifference: number,
{ viewerTimezone, hourDifference }: PostFilterContext,
) {
// nothing to compare against until the browser has reported its timezone
if (viewerTimezone === null) return true;
return (
Math.abs(hourDifference(post.timezone, viewerTimezone)) <= maxHourDifference
);
}
function matchesPlusTier(post: FilterablePost, plusTier: number) {
return checkMatchesSomeUserInPost(
post,
(user) => user.plusTier && user.plusTier <= plusTier,
);
}
function matchesMaxTier(
post: FilterablePost,
maxTier: TierName,
tiersMap: TiersMap,
) {
return checkMatchesSomeUserInPost(post, (user) => {
const tiers = tiersMap.get(user.id);
if (!tiers) return false;
if (tiers.latest && compareTwoTiers(tiers.latest.name, maxTier) >= 0) {
return true;
}
if (tiers.previous && compareTwoTiers(tiers.previous.name, maxTier) >= 0) {
return true;
}
return false;
});
}
function matchesMinTier(
post: FilterablePost,
minTier: TierName,
tiersMap: TiersMap,
) {
return checkMatchesSomeUserInPost(post, (user) => {
const tiers = tiersMap.get(user.id);
if (!tiers) return false;
if (tiers.latest && compareTwoTiers(tiers.latest.name, minTier) <= 0) {
return true;
}
if (tiers.previous && compareTwoTiers(tiers.previous.name, minTier) <= 0) {
return true;
}
return false;
});
}
const checkMatchesSomeUserInPost = (
post: FilterablePost,
check: (user: FilterablePost["author"]) => boolean | undefined | null | 0,
) => {
if (check(post.author)) return true;
if (post.team?.members.some(check)) return true;
return false;
};
const weaponIdToRelated = (weaponSplId: MainWeaponId) => {
const result: MainWeaponId[] = [];
for (const id of mainWeaponIds) {
if (weaponIdToBaseWeaponId(id) === weaponIdToBaseWeaponId(weaponSplId)) {
result.push(id);
}
}
return result;
};

View File

@@ -0,0 +1,22 @@
import { describe, expect, test } from "vitest";
import { hourDifferenceBetweenTimezones } from "./timezone";
describe("hourDifferenceBetweenTimezones", () => {
// Pacific/Kiritimati is UTC+14 and Pacific/Honolulu is UTC-10, neither
// observes DST -> their local clocks show the exact same time year round
test("timezones with identical local clock time across the date line have zero difference", () => {
expect(
hourDifferenceBetweenTimezones("Pacific/Kiritimati", "Pacific/Honolulu"),
).toBe(0);
});
// Asia/Tokyo (UTC+9) and Pacific/Honolulu (UTC-10) are 19 hours apart on
// paper but their local clocks only differ by 5 hours
test("difference is never more than 12 hours in either direction", () => {
expect(
Math.abs(
hourDifferenceBetweenTimezones("Asia/Tokyo", "Pacific/Honolulu"),
),
).toBeLessThanOrEqual(12);
});
});

View File

@@ -0,0 +1,56 @@
// timezone example is 'Asia/Tokyo'
/** Hours between the local clock times of two timezones, wrapped to [-12, 12]. */
export function hourDifferenceBetweenTimezones(
timezone1: string,
timezone2: string,
) {
return createTimezoneHourDifference()(timezone1, timezone2);
}
/**
* Same as {@link hourDifferenceBetweenTimezones}, but resolving each timezone's
* offset only once. Resolving an offset is expensive, so comparing many
* timezones (filtering a list of posts, say) should share one instance.
*/
export function createTimezoneHourDifference() {
const offsets = new Map<string, number>();
const offsetOf = (timezone: string) => {
const cached = offsets.get(timezone);
if (typeof cached === "number") return cached;
const offset = getTimezoneOffset(timezone);
offsets.set(timezone, offset);
return offset;
};
return (timezone1: string, timezone2: string) => {
const rawDifference = (offsetOf(timezone1) - offsetOf(timezone2)) / 60;
// wrap to [-12, 12] so timezones across the date line compare by local clock time
return ((((rawDifference + 12) % 24) + 24) % 24) - 12;
};
}
// https://stackoverflow.com/a/29268535
function getTimezoneOffset(timeZone: string) {
const date = new Date();
// Abuse the Intl API to get a local ISO 8601 string for a given time zone.
let iso = date
.toLocaleString("en-CA", { timeZone, hour12: false })
.replace(", ", "T");
// Include the milliseconds from the original timestamp
iso += `.${date.getMilliseconds().toString().padStart(3, "0")}`;
// Lie to the Date object constructor that it's a UTC time.
const lie = new Date(`${iso}Z`);
// Return the difference in timestamps, as minutes
// Positive values are West of GMT, opposite of ISO 8601
// this matches the output of `Date.getTimeZoneOffset`
return -(lie.getTime() - date.getTime()) / 60 / 1000;
}

View File

@@ -0,0 +1,376 @@
import type { Tables } from "~/db/tables";
export const LFG_TYPES = [
"PLAYER_FOR_TEAM",
"PLAYER_FOR_COACH",
"TEAM_FOR_PLAYER",
"TEAM_FOR_COACH",
"TEAM_FOR_SCRIM",
"COACH_FOR_TEAM",
] as const;
export type LFGType = (typeof LFG_TYPES)[number];
export const LFG = {
MIN_TEXT_LENGTH: 1,
MAX_TEXT_LENGTH: 2_000,
POST_FRESHNESS_DAYS: 30 as const,
MAX_WEAPON_FILTERS: 10,
POSTS_PER_PAGE: 24,
types: LFG_TYPES,
};
export const TEAM_POST_TYPES: Array<Tables["LFGPost"]["type"]> = [
"TEAM_FOR_COACH",
"TEAM_FOR_PLAYER",
"TEAM_FOR_SCRIM",
];
export const TIMEZONES = [
"Africa/Abidjan",
"Africa/Accra",
"Africa/Algiers",
"Africa/Bissau",
"Africa/Cairo",
"Africa/Casablanca",
"Africa/Ceuta",
"Africa/El_Aaiun",
"Africa/Johannesburg",
"Africa/Juba",
"Africa/Khartoum",
"Africa/Lagos",
"Africa/Maputo",
"Africa/Monrovia",
"Africa/Nairobi",
"Africa/Ndjamena",
"Africa/Sao_Tome",
"Africa/Tripoli",
"Africa/Tunis",
"Africa/Windhoek",
"America/Adak",
"America/Anchorage",
"America/Araguaina",
"America/Argentina/Buenos_Aires",
"America/Argentina/Catamarca",
"America/Argentina/Cordoba",
"America/Argentina/Jujuy",
"America/Argentina/La_Rioja",
"America/Argentina/Mendoza",
"America/Argentina/Rio_Gallegos",
"America/Argentina/Salta",
"America/Argentina/San_Juan",
"America/Argentina/San_Luis",
"America/Argentina/Tucuman",
"America/Argentina/Ushuaia",
"America/Asuncion",
"America/Atikokan",
"America/Bahia",
"America/Bahia_Banderas",
"America/Barbados",
"America/Belem",
"America/Belize",
"America/Blanc-Sablon",
"America/Boa_Vista",
"America/Bogota",
"America/Boise",
"America/Cambridge_Bay",
"America/Campo_Grande",
"America/Cancun",
"America/Caracas",
"America/Cayenne",
"America/Chicago",
"America/Chihuahua",
"America/Costa_Rica",
"America/Creston",
"America/Cuiaba",
"America/Curacao",
"America/Danmarkshavn",
"America/Dawson",
"America/Dawson_Creek",
"America/Denver",
"America/Detroit",
"America/Edmonton",
"America/Eirunepe",
"America/El_Salvador",
"America/Fort_Nelson",
"America/Fortaleza",
"America/Glace_Bay",
"America/Godthab",
"America/Goose_Bay",
"America/Grand_Turk",
"America/Guatemala",
"America/Guayaquil",
"America/Guyana",
"America/Halifax",
"America/Havana",
"America/Hermosillo",
"America/Indiana/Indianapolis",
"America/Indiana/Knox",
"America/Indiana/Marengo",
"America/Indiana/Petersburg",
"America/Indiana/Tell_City",
"America/Indiana/Vevay",
"America/Indiana/Vincennes",
"America/Indiana/Winamac",
"America/Inuvik",
"America/Iqaluit",
"America/Jamaica",
"America/Juneau",
"America/Kentucky/Louisville",
"America/Kentucky/Monticello",
"America/La_Paz",
"America/Lima",
"America/Los_Angeles",
"America/Maceio",
"America/Managua",
"America/Manaus",
"America/Martinique",
"America/Matamoros",
"America/Mazatlan",
"America/Menominee",
"America/Merida",
"America/Metlakatla",
"America/Mexico_City",
"America/Miquelon",
"America/Moncton",
"America/Monterrey",
"America/Montevideo",
"America/Nassau",
"America/New_York",
"America/Nipigon",
"America/Nome",
"America/Noronha",
"America/North_Dakota/Beulah",
"America/North_Dakota/Center",
"America/North_Dakota/New_Salem",
"America/Ojinaga",
"America/Panama",
"America/Pangnirtung",
"America/Paramaribo",
"America/Phoenix",
"America/Port_of_Spain",
"America/Port-au-Prince",
"America/Porto_Velho",
"America/Puerto_Rico",
"America/Punta_Arenas",
"America/Rainy_River",
"America/Rankin_Inlet",
"America/Recife",
"America/Regina",
"America/Resolute",
"America/Rio_Branco",
"America/Santarem",
"America/Santiago",
"America/Santo_Domingo",
"America/Sao_Paulo",
"America/Scoresbysund",
"America/Sitka",
"America/St_Johns",
"America/Swift_Current",
"America/Tegucigalpa",
"America/Thule",
"America/Thunder_Bay",
"America/Tijuana",
"America/Toronto",
"America/Vancouver",
"America/Whitehorse",
"America/Winnipeg",
"America/Yakutat",
"America/Yellowknife",
"Antarctica/Casey",
"Antarctica/Davis",
"Antarctica/Macquarie",
"Antarctica/Mawson",
"Antarctica/Palmer",
"Antarctica/Rothera",
"Antarctica/Syowa",
"Antarctica/Troll",
"Antarctica/Vostok",
"Asia/Almaty",
"Asia/Amman",
"Asia/Anadyr",
"Asia/Aqtau",
"Asia/Aqtobe",
"Asia/Ashgabat",
"Asia/Atyrau",
"Asia/Baghdad",
"Asia/Baku",
"Asia/Bangkok",
"Asia/Barnaul",
"Asia/Beirut",
"Asia/Bishkek",
"Asia/Brunei",
"Asia/Chita",
"Asia/Choibalsan",
"Asia/Colombo",
"Asia/Damascus",
"Asia/Dhaka",
"Asia/Dili",
"Asia/Dubai",
"Asia/Dushanbe",
"Asia/Famagusta",
"Asia/Gaza",
"Asia/Hebron",
"Asia/Ho_Chi_Minh",
"Asia/Hong_Kong",
"Asia/Hovd",
"Asia/Irkutsk",
"Asia/Jakarta",
"Asia/Jayapura",
"Asia/Jerusalem",
"Asia/Kabul",
"Asia/Kamchatka",
"Asia/Karachi",
"Asia/Kathmandu",
"Asia/Khandyga",
"Asia/Kolkata",
"Asia/Krasnoyarsk",
"Asia/Kuala_Lumpur",
"Asia/Kuching",
"Asia/Macau",
"Asia/Magadan",
"Asia/Makassar",
"Asia/Manila",
"Asia/Nicosia",
"Asia/Novokuznetsk",
"Asia/Novosibirsk",
"Asia/Omsk",
"Asia/Oral",
"Asia/Pontianak",
"Asia/Pyongyang",
"Asia/Qatar",
"Asia/Qyzylorda",
"Asia/Riyadh",
"Asia/Sakhalin",
"Asia/Samarkand",
"Asia/Seoul",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Srednekolymsk",
"Asia/Taipei",
"Asia/Tashkent",
"Asia/Tbilisi",
"Asia/Tehran",
"Asia/Thimphu",
"Asia/Tokyo",
"Asia/Tomsk",
"Asia/Ulaanbaatar",
"Asia/Urumqi",
"Asia/Ust-Nera",
"Asia/Vladivostok",
"Asia/Yakutsk",
"Asia/Yangon",
"Asia/Yekaterinburg",
"Asia/Yerevan",
"Atlantic/Azores",
"Atlantic/Bermuda",
"Atlantic/Canary",
"Atlantic/Cape_Verde",
"Atlantic/Faroe",
"Atlantic/Madeira",
"Atlantic/Reykjavik",
"Atlantic/South_Georgia",
"Atlantic/Stanley",
"Australia/Adelaide",
"Australia/Brisbane",
"Australia/Broken_Hill",
"Australia/Currie",
"Australia/Darwin",
"Australia/Eucla",
"Australia/Hobart",
"Australia/Lindeman",
"Australia/Lord_Howe",
"Australia/Melbourne",
"Australia/Perth",
"Australia/Sydney",
"Europe/Amsterdam",
"Europe/Andorra",
"Europe/Astrakhan",
"Europe/Athens",
"Europe/Belgrade",
"Europe/Berlin",
"Europe/Brussels",
"Europe/Bucharest",
"Europe/Budapest",
"Europe/Chisinau",
"Europe/Copenhagen",
"Europe/Dublin",
"Europe/Gibraltar",
"Europe/Helsinki",
"Europe/Istanbul",
"Europe/Kaliningrad",
"Europe/Kiev",
"Europe/Kirov",
"Europe/Lisbon",
"Europe/London",
"Europe/Luxembourg",
"Europe/Madrid",
"Europe/Malta",
"Europe/Minsk",
"Europe/Monaco",
"Europe/Moscow",
"Europe/Oslo",
"Europe/Paris",
"Europe/Prague",
"Europe/Riga",
"Europe/Rome",
"Europe/Samara",
"Europe/Saratov",
"Europe/Simferopol",
"Europe/Sofia",
"Europe/Stockholm",
"Europe/Tallinn",
"Europe/Tirane",
"Europe/Ulyanovsk",
"Europe/Uzhgorod",
"Europe/Vienna",
"Europe/Vilnius",
"Europe/Volgograd",
"Europe/Warsaw",
"Europe/Zaporozhye",
"Europe/Zurich",
"Indian/Chagos",
"Indian/Christmas",
"Indian/Cocos",
"Indian/Kerguelen",
"Indian/Mahe",
"Indian/Maldives",
"Indian/Mauritius",
"Indian/Reunion",
"Pacific/Apia",
"Pacific/Auckland",
"Pacific/Bougainville",
"Pacific/Chatham",
"Pacific/Chuuk",
"Pacific/Easter",
"Pacific/Efate",
"Pacific/Enderbury",
"Pacific/Fakaofo",
"Pacific/Fiji",
"Pacific/Funafuti",
"Pacific/Galapagos",
"Pacific/Gambier",
"Pacific/Guadalcanal",
"Pacific/Guam",
"Pacific/Honolulu",
"Pacific/Kiritimati",
"Pacific/Kosrae",
"Pacific/Kwajalein",
"Pacific/Majuro",
"Pacific/Marquesas",
"Pacific/Nauru",
"Pacific/Niue",
"Pacific/Norfolk",
"Pacific/Noumea",
"Pacific/Pago_Pago",
"Pacific/Palau",
"Pacific/Pitcairn",
"Pacific/Pohnpei",
"Pacific/Port_Moresby",
"Pacific/Rarotonga",
"Pacific/Tahiti",
"Pacific/Tarawa",
"Pacific/Tongatapu",
"Pacific/Wake",
"Pacific/Wallis",
];

View File

@@ -0,0 +1,51 @@
import { z } from "zod";
import { LANGUAGE_OPTIONS } from "~/features/settings/match-profile-schemas";
import {
checkboxGroup,
idConstantOptional,
selectDynamic,
selectDynamicOptional,
textArea,
} from "~/form/fields";
import { _action, id } from "~/utils/zod";
import { LFG, TIMEZONES } from "./lfg-constants";
export const lfgNewSchema = z
.object({
postId: idConstantOptional(),
type: selectDynamic({ label: "labels.type" }),
timezone: selectDynamic({ label: "labels.timezone" }),
postText: textArea({
label: "labels.text",
maxLength: LFG.MAX_TEXT_LENGTH,
}),
plusTierVisibility: selectDynamicOptional({
label: "labels.visibility",
}),
languages: checkboxGroup({
label: "labels.languages",
items: LANGUAGE_OPTIONS,
}),
})
.refine(
(data) => LFG.types.includes(data.type as (typeof LFG.types)[number]),
{
message: "Invalid LFG type",
path: ["type"],
},
)
.refine((data) => TIMEZONES.includes(data.timezone), {
message: "Invalid timezone",
path: ["timezone"],
});
export const lfgActionSchema = z.union([
z.object({
_action: _action("DELETE_POST"),
id,
}),
z.object({
_action: _action("BUMP_POST"),
id,
}),
]);

View File

@@ -0,0 +1,48 @@
import { describe, test } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/search-params/search-params-test-utils";
import { lfgNewSearchParams, lfgSearchParams } from "./lfg-search-params";
describe("lfgSearchParams", () => {
test("round-trips", () => {
assertRoundTrips(lfgSearchParams, {
page: [1, 2, 9],
post: [null, 1, 123],
weapons: [[], [0], [0, 10, 4001]],
type: [null, "PLAYER_FOR_TEAM", "COACH_FOR_TEAM"],
timezone: [null, 0, 3, 12],
language: [null, "en", "ja"],
plusTier: [null, 1, 3],
minTier: [null, "GOLD", "LEVIATHAN"],
maxTier: [null, "PLATINUM", "IRON"],
});
});
test("decodes garbage to defaults", () => {
assertDecodesToDefault(lfgSearchParams, "page", [["0"], ["-1"], ["abc"]]);
assertDecodesToDefault(lfgSearchParams, "post", [["abc"], ["0"]]);
assertDecodesToDefault(lfgSearchParams, "type", [["NOT_A_TYPE"], [""]]);
assertDecodesToDefault(lfgSearchParams, "timezone", [
["13"],
["-1"],
["abc"],
]);
assertDecodesToDefault(lfgSearchParams, "language", [["xx"]]);
assertDecodesToDefault(lfgSearchParams, "plusTier", [["0"], ["4"]]);
assertDecodesToDefault(lfgSearchParams, "minTier", [["gold"], ["XX"]]);
});
});
describe("lfgNewSearchParams", () => {
test("round-trips", () => {
assertRoundTrips(lfgNewSearchParams, {
postId: [1, 123],
});
});
test("garbage decodes to default", () => {
assertDecodesToDefault(lfgNewSearchParams, "postId", [["abc"], ["0"]]);
});
});

View File

@@ -0,0 +1,42 @@
import { z } from "zod";
import { TIERS, type TierName } from "~/features/mmr/mmr-constants";
import {
languagesUnified,
type UnifiedLanguageCode,
} from "~/modules/i18n/config";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
import { numericEnum } from "~/utils/zod";
import { LFG, LFG_TYPES } from "./lfg-constants";
const LANGUAGE_CODES = languagesUnified.map((language) => language.code) as [
UnifiedLanguageCode,
...UnifiedLanguageCode[],
];
const TIER_NAMES = TIERS.map((tier) => tier.name) as [TierName, ...TierName[]];
const FILTER_OPTIONS = { loader: true, resets: ["page", "post"] };
export const lfgSearchParams = SearchParams.define({
page: SP.page({ resets: ["post"] }),
/** Post to jump to: the loader serves the page containing it, overriding `page`. */
post: SP.param(z.number().int().positive().nullable(), { loader: true }),
weapons: SP.param(
z.array(numericEnum(mainWeaponIds)).max(LFG.MAX_WEAPON_FILTERS),
{ default: [], ...FILTER_OPTIONS },
),
type: SP.param(z.enum(LFG_TYPES).nullable(), FILTER_OPTIONS),
timezone: SP.param(
z.number().int().min(0).max(12).nullable(),
FILTER_OPTIONS,
),
language: SP.param(z.enum(LANGUAGE_CODES).nullable(), FILTER_OPTIONS),
plusTier: SP.param(z.number().int().min(1).max(3).nullable(), FILTER_OPTIONS),
minTier: SP.param(z.enum(TIER_NAMES).nullable(), FILTER_OPTIONS),
maxTier: SP.param(z.enum(TIER_NAMES).nullable(), FILTER_OPTIONS),
});
export const lfgNewSearchParams = SearchParams.define({
postId: SP.param(z.number().int().positive().nullable(), { loader: true }),
});

View File

@@ -0,0 +1,14 @@
import type { LFGType } from "~/features/lfg/lfg-constants";
import type { TierName } from "~/features/mmr/mmr-constants";
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
export interface LFGFilterValues {
weapons: MainWeaponId[];
type: LFGType | null;
timezone: number | null;
language: UnifiedLanguageCode | null;
plusTier: number | null;
minTier: TierName | null;
maxTier: TierName | null;
}

View File

@@ -0,0 +1,5 @@
import { LFG_PAGE } from "~/utils/urls";
import { lfgNewSearchParams } from "./lfg-search-params";
export const lfgNewPostPage = (postId?: number) =>
lfgNewSearchParams.href(`${LFG_PAGE}/new`, { postId: postId ?? null });

View File

@@ -0,0 +1,48 @@
import type { LoaderFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import type { Unpacked } from "~/utils/types";
import * as LFGRepository from "../LFGRepository.server";
import { lfgNewSearchParams } from "../lfg-search-params";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = requireUser();
const userProfileData = await UserRepository.findProfileByIdentifier(
String(user.id),
);
const userMatchProfile = await MatchProfileRepository.findSettingsByUserId(
user.id,
);
const allPosts = await LFGRepository.findAllPosts(user);
const postToEdit = searchParamsToBuildToEdit(request, user.id, allPosts);
return {
team: userProfileData?.team,
weaponPool: userProfileData?.weapons,
languages: postToEdit?.languages ?? userMatchProfile.languages,
postToEdit,
userPostTypes: userPostTypes(allPosts, user.id),
};
};
const searchParamsToBuildToEdit = (
request: LoaderFunctionArgs["request"],
userId: number,
allPosts: Unpacked<ReturnType<typeof LFGRepository.findAllPosts>>,
) => {
const { postId } = lfgNewSearchParams.parse(request);
if (postId === null) return;
const post = allPosts.find((p) => p.id === postId && p.author.id === userId);
return post;
};
const userPostTypes = (
allPosts: Unpacked<ReturnType<typeof LFGRepository.findAllPosts>>,
userId: number,
) =>
allPosts.filter((post) => post.author.id === userId).map((post) => post.type);

View File

@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, test } from "vitest";
import * as LFGPostFactory from "~/db/seed/factories/LFGPostFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import type { SerializeFrom } from "~/utils/remix";
import { wrappedLoader } from "~/utils/Test";
import { LFG_PAGE } from "~/utils/urls";
import * as LFGRepository from "../LFGRepository.server";
import { LFG } from "../lfg-constants";
import { lfgSearchParams } from "../lfg-search-params";
import { loader } from "./lfg.server";
const users = UserFactory.pool();
const lfgLoader = wrappedLoader<SerializeFrom<typeof loader>>({ loader });
// one post per author per type is all the database allows
const createPlayerPosts = async () => {
for (const authorId of users.ids()) {
await LFGPostFactory.create({ authorId, type: "PLAYER_FOR_TEAM" });
}
};
const orderedPostIds = async () =>
(await LFGRepository.findAllPosts()).map((post) => post.id);
const postIds = (data: SerializeFrom<typeof loader>) =>
data.posts.map((post) => post.id);
describe("lfg loader", () => {
beforeEach(async () => {
await users.create(LFG.POSTS_PER_PAGE + 2);
});
test("slices the posts of the page asked for", async () => {
await createPlayerPosts();
const allIds = await orderedPostIds();
const firstPage = await lfgLoader({ url: LFG_PAGE });
const secondPage = await lfgLoader({
url: lfgSearchParams.href(LFG_PAGE, { page: 2 }),
});
expect(firstPage.pagesCount).toBe(2);
expect(postIds(firstPage)).toEqual(allIds.slice(0, LFG.POSTS_PER_PAGE));
expect(postIds(secondPage)).toEqual(allIds.slice(LFG.POSTS_PER_PAGE));
});
test("serves the page containing the linked post", async () => {
await createPlayerPosts();
const linkedPostId = (await orderedPostIds())[LFG.POSTS_PER_PAGE];
const data = await lfgLoader({
url: lfgSearchParams.href(LFG_PAGE, { post: linkedPostId }),
});
expect(data.currentPage).toBe(2);
expect(postIds(data)).toContain(linkedPostId);
});
test("falls back to the page param when the linked post is filtered out", async () => {
await createPlayerPosts();
const { id: coachPostId } = await LFGPostFactory.create({
authorId: users.id(1),
type: "COACH_FOR_TEAM",
});
const data = await lfgLoader({
url: lfgSearchParams.href(LFG_PAGE, {
post: coachPostId,
type: "PLAYER_FOR_TEAM",
page: 2,
}),
});
expect(data.currentPage).toBe(2);
expect(postIds(data)).not.toContain(coachPostId);
});
test("falls back to the page param when the linked post does not exist", async () => {
await createPlayerPosts();
const data = await lfgLoader({
url: lfgSearchParams.href(LFG_PAGE, { post: 999_999 }),
});
expect(data.currentPage).toBe(1);
});
});

View File

@@ -0,0 +1,104 @@
import type { LoaderFunctionArgs } from "react-router";
import * as R from "remeda";
import { getUser } from "~/features/auth/core/user.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { userSkills } from "~/features/mmr/tiered.server";
import { getViewerTimezone } from "~/features/timezone/timezone-context.server";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import { paginate } from "~/utils/remix.server";
import type { Unpacked } from "~/utils/types";
import { filterPosts, type TiersMap } from "../core/filtering";
import * as LFGRepository from "../LFGRepository.server";
import { LFG } from "../lfg-constants";
import { lfgSearchParams } from "../lfg-search-params";
export const loader = async ({ request, url }: LoaderFunctionArgs) => {
const user = getUser();
const { page, post, ...filters } = lfgSearchParams.parse(request);
const viewerTimezone = getViewerTimezone();
const allPosts = await LFGRepository.findAllPosts(user);
const filteredPosts = filterPosts(allPosts, filters, {
tiersMap: await postsUsersTiersMap(allPosts),
viewerTimezone,
});
const postIndex =
post !== null
? filteredPosts.findIndex((filteredPost) => filteredPost.id === post)
: -1;
const pageContainingPost =
postIndex === -1 ? null : Math.floor(postIndex / LFG.POSTS_PER_PAGE) + 1;
const pagination = paginate({
url,
page: pageContainingPost ?? page,
pageSize: LFG.POSTS_PER_PAGE,
totalCount: filteredPosts.length,
});
const posts = filteredPosts.slice(
(pagination.currentPage - 1) * LFG.POSTS_PER_PAGE,
pagination.currentPage * LFG.POSTS_PER_PAGE,
);
const cardUserIds = R.unique(
posts.flatMap((post) => [
post.author.id,
...(post.team?.members ?? []).map((member) => member.id),
]),
);
return {
posts,
viewerTimezone,
...(await UserCardRepository.findAllByUserIdsCached({
userIds: cardUserIds,
})),
...pagination,
};
};
async function postsUsersTiersMap(
posts: Unpacked<ReturnType<typeof LFGRepository.findAllPosts>>,
): Promise<TiersMap> {
const latestSeason = Seasons.currentOrPrevious()!.nth;
const previousSeason = latestSeason - 1;
const [
{ userSkills: latestSeasonSkills },
{ userSkills: previousSeasonSkills },
] = await Promise.all([userSkills(latestSeason), userSkills(previousSeason)]);
const uniqueUsers = new Set<number>();
for (const post of posts) {
uniqueUsers.add(post.author.id);
for (const user of post.team?.members ?? []) {
uniqueUsers.add(user.id);
}
}
const userSkillsMap: TiersMap = new Map();
for (const userId of uniqueUsers) {
const tiers = {
latest:
latestSeasonSkills[userId] && !latestSeasonSkills[userId].approximate
? latestSeasonSkills[userId].tier
: undefined,
previous:
previousSeasonSkills[userId] &&
!previousSeasonSkills[userId].approximate
? previousSeasonSkills[userId].tier
: undefined,
};
if (tiers.latest || tiers.previous) {
userSkillsMap.set(userId, tiers);
}
}
return userSkillsMap;
}

View File

@@ -0,0 +1,3 @@
.post {
scroll-margin-top: 6rem;
}

View File

@@ -0,0 +1,176 @@
import { ChevronLeft } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useLoaderData } from "react-router";
import { LinkButton } from "~/components/elements/Button";
import { FormMessage } from "~/components/FormMessage";
import { WeaponImage } from "~/components/Image";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import type { Tables } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { FormField } from "~/form/FormField";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import type { SelectOption } from "~/form/types";
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
import { useHasRole } from "~/modules/permissions/hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { LFG_PAGE, navIconUrl, userEditProfilePage } from "~/utils/urls";
import { action } from "../actions/lfg.new.server";
import { LFG, TEAM_POST_TYPES, TIMEZONES } from "../lfg-constants";
import { lfgNewSchema } from "../lfg-schemas";
import { loader } from "../loaders/lfg.new.server";
export { action, loader };
export const handle: SendouRouteHandle = {
i18n: ["lfg"],
breadcrumb: () => ({
imgPath: navIconUrl("lfg"),
href: LFG_PAGE,
type: "IMAGE",
}),
};
const browserTimezone = () => {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (TIMEZONES.includes(tz)) return tz;
} catch {
// ignore
}
return TIMEZONES[0];
};
export default function LFGNewPostPage() {
const isPlusServerMember = useHasRole("PLUS_SERVER_MEMBER");
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["common", "lfg"]);
const user = useUser();
const availableTypes = useAvailablePostTypes();
if (availableTypes.length === 0) {
return (
<Main halfWidth className="stack items-center">
<h2 className="text-lg mb-4">{t("lfg:new.noMorePosts")}</h2>
<LinkButton to={LFG_PAGE} icon={<ChevronLeft />}>
{t("common:actions.goBack")}
</LinkButton>
</Main>
);
}
const typeOptions: SelectOption[] = availableTypes.map((type) => ({
value: type,
label: `${t(`lfg:types.${type}`)}${data.team && TEAM_POST_TYPES.includes(type) ? ` (${data.team.name})` : ""}`,
}));
const timezoneOptions: SelectOption[] = TIMEZONES.map((tz) => ({
value: tz,
label: tz,
}));
const plusTierOptions: SelectOption[] = [1, 2, 3]
.filter((tier) => user?.plusTier && tier >= user.plusTier)
.map((tier) => ({
value: String(tier),
label: `+${tier} ${tier > 1 ? t("lfg:filters.orAbove") : ""}`.trim(),
}));
return (
<Main halfWidth>
<SendouForm
schema={lfgNewSchema}
title={data.postToEdit ? "Editing LFG post" : "New LFG post"}
defaultValues={{
postId: data.postToEdit?.id,
type: data.postToEdit?.type ?? availableTypes[0],
timezone: data.postToEdit?.timezone ?? browserTimezone(),
postText: data.postToEdit?.text ?? "",
plusTierVisibility: data.postToEdit?.plusTierVisibility
? String(data.postToEdit.plusTierVisibility)
: null,
languages: (data.languages ?? []) as UnifiedLanguageCode[],
}}
>
<FormField
name="type"
options={typeOptions}
disabled={!!data.postToEdit}
/>
<FormField name="timezone" options={timezoneOptions} />
<FormField name="postText" />
<ConditionalFields
isPlusServerMember={isPlusServerMember}
plusTierOptions={plusTierOptions}
/>
<FormField name="languages" />
<ConditionalWeaponPool />
</SendouForm>
</Main>
);
}
function ConditionalFields({
isPlusServerMember,
plusTierOptions,
}: {
isPlusServerMember: boolean;
plusTierOptions: SelectOption[];
}) {
const { values } = useFormFieldContext();
const type = values.type as Tables["LFGPost"]["type"];
return isPlusServerMember && type !== "COACH_FOR_TEAM" ? (
<FormField name="plusTierVisibility" options={plusTierOptions} />
) : null;
}
function ConditionalWeaponPool() {
const { values } = useFormFieldContext();
const type = values.type as Tables["LFGPost"]["type"];
return type !== "COACH_FOR_TEAM" ? <WeaponPool /> : null;
}
function WeaponPool() {
const { t } = useTranslation(["lfg"]);
const user = useUser();
const data = useLoaderData<typeof loader>();
return (
<div>
<Label>{t("lfg:new.weaponPool.header")}</Label>
<div className="stack horizontal sm">
{data.weaponPool?.map(({ weaponSplId }) => (
<WeaponImage
key={weaponSplId}
weaponSplId={weaponSplId}
size={32}
variant="build"
/>
))}
</div>
<FormMessage type="info">
{t("lfg:new.editOn")}{" "}
<Link to={userEditProfilePage(user!)}>
{t("lfg:new.weaponPool.userProfile")}
</Link>
</FormMessage>
</div>
);
}
const useAvailablePostTypes = () => {
const data = useLoaderData<typeof loader>();
return (
LFG.types
// can't look for a team, if not in one
.filter((type) => data.team || !TEAM_POST_TYPES.includes(type))
// can't post two posts of same type
.filter(
(type) =>
!data.userPostTypes.includes(type) || data.postToEdit?.type === type,
)
);
};

View File

@@ -0,0 +1,370 @@
import clsx from "clsx";
import { add, sub } from "date-fns";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useLoaderData } from "react-router";
import * as R from "remeda";
import { ActionButton } from "~/components/ActionButton";
import { Alert } from "~/components/Alert";
import { SendouButton } from "~/components/elements/Button";
import { FilterBar } from "~/components/filter-bar/FilterBar";
import { WeaponImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { Pagination } from "~/components/Pagination";
import { WeaponSelect } from "~/components/WeaponSelect";
import { useUser } from "~/features/auth/core/user";
import { TIERS } from "~/features/mmr/mmr-constants";
import { timezoneMiddleware } from "~/features/timezone/timezone-middleware.server";
import { useSearchParamPagination } from "~/hooks/useSearchParamPagination";
import { languagesUnified } from "~/modules/i18n/config";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import { databaseTimestampToDate } from "~/utils/dates";
import { metaTags, type SerializeFrom } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import type { Unpacked } from "~/utils/types";
import { LFG_PAGE, navIconUrl } from "~/utils/urls";
import { action } from "../actions/lfg.server";
import { LFGPost } from "../components/LFGPost";
import { LFG } from "../lfg-constants";
import { lfgActionSchema } from "../lfg-schemas";
import { lfgSearchParams } from "../lfg-search-params";
import { loader } from "../loaders/lfg.server";
import type { Route } from "./+types/lfg";
import styles from "./lfg.module.css";
export { action, loader };
export const middleware: Route.MiddlewareFunction[] = [timezoneMiddleware];
export const handle: SendouRouteHandle = {
i18n: ["lfg", "user", "q"],
breadcrumb: () => ({
imgPath: navIconUrl("lfg"),
href: LFG_PAGE,
type: "IMAGE",
}),
};
export const meta: MetaFunction = (args) => {
return metaTags({
title: "LFG",
ogTitle: "Splatoon LFG (looking for players, teams & coaches)",
description:
"Find people to play Splatoon with. Create a post or browse existing ones. For looking players, teams, scrim partners and coaches alike.",
location: args.location,
});
};
export type LFGLoaderData = SerializeFrom<typeof loader>;
export default function LFGPage() {
const { t } = useTranslation(["common", "lfg"]);
const data = useLoaderData<typeof loader>();
const pagination = useSearchParamPagination({
definition: lfgSearchParams,
currentPage: data.currentPage,
pagesCount: data.pagesCount,
});
return (
<Main className="stack xl">
<Filters />
{data.posts.length > 0 ? (
<>
<PostsList posts={data.posts} />
{data.pagesCount > 1 ? <Pagination {...pagination} /> : null}
</>
) : (
<div className="text-lighter text-lg font-semi-bold text-center mt-6">
{t("lfg:noPosts")}
</div>
)}
</Main>
);
}
function PostsList({ posts }: { posts: LFGLoaderData["posts"] }) {
const user = useUser();
const showExpiryAlert = (post: Unpacked<LFGLoaderData["posts"]>) => {
if (post.author.id !== user?.id) return false;
const expiryDate = add(databaseTimestampToDate(post.updatedAt), {
days: LFG.POST_FRESHNESS_DAYS,
});
const expiryCloseDate = sub(expiryDate, { days: 7 });
if (new Date() < expiryCloseDate) return false;
return true;
};
return (
<>
{posts.map((post) => (
<div
key={post.id}
id={String(post.id)}
className={clsx("stack sm", styles.post)}
>
{showExpiryAlert(post) ? <PostExpiryAlert postId={post.id} /> : null}
<LFGPost post={post} />
</div>
))}
</>
);
}
function Filters() {
const { t } = useTranslation(["lfg"]);
const { viewerTimezone } = useLoaderData<typeof loader>();
const [
{ weapons, type, timezone, language, plusTier, minTier, maxTier },
setParams,
] = useSearchParamsTyped(lfgSearchParams);
return (
<FilterBar
pills={[
{
key: "weapons",
name: t("lfg:filters.Weapon"),
formattedValue:
weapons.length > 0 ? (
<span className="stack horizontal xs">
{weapons.map((weaponSplId) => (
<WeaponImage
key={weaponSplId}
weaponSplId={weaponSplId}
size={18}
variant="badge"
/>
))}
</span>
) : null,
onRemove: () => setParams({ weapons: [] }),
popover: (
<WeaponsPopover
weapons={weapons}
onChange={(newWeapons) => setParams({ weapons: newWeapons })}
/>
),
},
{
key: "type",
name: t("lfg:filters.Type"),
formattedValue: type !== null ? t(`lfg:types.${type}`) : null,
onAdd: () => setParams({ type: "PLAYER_FOR_TEAM" }),
onRemove: () => setParams({ type: null }),
popover: (
<select
aria-label={t("lfg:filters.Type")}
className="w-full"
value={type ?? "PLAYER_FOR_TEAM"}
onChange={(e) =>
setParams({ type: e.target.value as typeof type })
}
>
{LFG.types.map((option) => (
<option key={option} value={option}>
{t(`lfg:types.${option}`)}
</option>
))}
</select>
),
},
{
key: "language",
name: t("lfg:filters.Language"),
formattedValue:
language !== null
? (languagesUnified.find((lang) => lang.code === language)
?.name ?? language)
: null,
onAdd: () => setParams({ language: "en" }),
onRemove: () => setParams({ language: null }),
popover: (
<select
aria-label={t("lfg:filters.Language")}
className="w-full"
value={language ?? "en"}
onChange={(e) =>
setParams({ language: e.target.value as typeof language })
}
>
{languagesUnified.map((option) => (
<option key={option.code} value={option.code}>
{option.name}
</option>
))}
</select>
),
},
{
key: "plusTier",
name: t("lfg:filters.PlusTier"),
formattedValue:
plusTier !== null
? plusTier === 1
? "+1"
: `+${plusTier} ${t("lfg:filters.orAbove")}`
: null,
onAdd: () => setParams({ plusTier: 3 }),
onRemove: () => setParams({ plusTier: null }),
popover: (
<select
aria-label={t("lfg:filters.PlusTier")}
className="w-full"
value={plusTier ?? 3}
onChange={(e) => setParams({ plusTier: Number(e.target.value) })}
>
<option value="1">+1</option>
<option value="2">+2 {t("lfg:filters.orAbove")}</option>
<option value="3">+3 {t("lfg:filters.orAbove")}</option>
</select>
),
},
{
key: "timezone",
name: t("lfg:filters.Timezone"),
// the filter is skipped by the loader while the viewer's timezone is
// unknown, so showing a value would claim a filtering that is not happening
formattedValue:
timezone !== null && viewerTimezone !== null
? `±${timezone}h`
: null,
onAdd: () => setParams({ timezone: 3 }),
onRemove: () => setParams({ timezone: null }),
popover: (
<input
aria-label={t("lfg:filters.Timezone")}
className="w-full"
type="number"
value={timezone ?? 3}
min={0}
max={12}
onChange={(e) => setParams({ timezone: Number(e.target.value) })}
/>
),
},
{
key: "minTier",
name: t("lfg:filters.MinTier"),
formattedValue:
minTier !== null ? R.capitalize(minTier.toLowerCase()) : null,
onAdd: () => setParams({ minTier: "GOLD" }),
onRemove: () => setParams({ minTier: null }),
popover: (
<TierSelect
label={t("lfg:filters.MinTier")}
value={minTier ?? "GOLD"}
onChange={(tier) => setParams({ minTier: tier })}
/>
),
},
{
key: "maxTier",
name: t("lfg:filters.MaxTier"),
formattedValue:
maxTier !== null ? R.capitalize(maxTier.toLowerCase()) : null,
onAdd: () => setParams({ maxTier: "PLATINUM" }),
onRemove: () => setParams({ maxTier: null }),
popover: (
<TierSelect
label={t("lfg:filters.MaxTier")}
value={maxTier ?? "PLATINUM"}
onChange={(tier) => setParams({ maxTier: tier })}
/>
),
},
]}
/>
);
}
function WeaponsPopover({
weapons,
onChange,
}: {
weapons: MainWeaponId[];
onChange: (weapons: MainWeaponId[]) => void;
}) {
return (
<div className="stack sm">
<WeaponSelect
disabledWeaponIds={weapons}
onChange={(weaponId) =>
onChange(
weapons.length >= LFG.MAX_WEAPON_FILTERS
? [...weapons.slice(1, LFG.MAX_WEAPON_FILTERS), weaponId]
: [...weapons, weaponId],
)
}
key={weapons.join("-")}
/>
{weapons.length > 0 ? (
<div className="stack horizontal sm flex-wrap">
{weapons.map((weapon) => (
<SendouButton
key={weapon}
variant="minimal"
onPress={() =>
onChange(weapons.filter((weaponId) => weaponId !== weapon))
}
>
<WeaponImage weaponSplId={weapon} size={32} variant="badge" />
</SendouButton>
))}
</div>
) : null}
</div>
);
}
function TierSelect({
label,
value,
onChange,
}: {
label: string;
value: (typeof TIERS)[number]["name"];
onChange: (tier: (typeof TIERS)[number]["name"]) => void;
}) {
return (
<select
aria-label={label}
className="w-full"
value={value}
onChange={(e) => onChange(e.target.value as typeof value)}
>
{TIERS.map((tier) => (
<option key={tier.name} value={tier.name}>
{R.capitalize(tier.name.toLowerCase())}
</option>
))}
</select>
);
}
function PostExpiryAlert({ postId }: { postId: number }) {
const { t } = useTranslation(["common", "lfg"]);
return (
<Alert variation="WARNING">
<div className="stack md horizontal items-center">
{t("lfg:expiring")}{" "}
<ActionButton
schema={lfgActionSchema}
action="BUMP_POST"
fields={{ id: postId }}
variant="outlined"
size="small"
>
{t("common:actions.clickHere")}
</ActionButton>
</div>
</Alert>
);
}