mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-26 04:57:08 -05:00
Expiring group Closes #733
This commit is contained in:
72
app/components/play/LookingInfoText.tsx
Normal file
72
app/components/play/LookingInfoText.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Form, useLoaderData } from "remix";
|
||||
import { LookingLoaderData } from "~/routes/play/looking";
|
||||
import * as React from "react";
|
||||
import { groupExpiredDates } from "~/core/play/utils";
|
||||
import clsx from "clsx";
|
||||
import { Button } from "../Button";
|
||||
|
||||
const CONTAINER_CLASSNAME = "play-looking__info-text";
|
||||
|
||||
export function LookingInfoText({ lastUpdated }: { lastUpdated: Date }) {
|
||||
const [_, forceUpdate] = React.useState(Math.random());
|
||||
const data = useLoaderData<LookingLoaderData>();
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
forceUpdate(Math.random());
|
||||
}, 10000); // 10 seconds
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const groupExpirationStatus = (():
|
||||
| undefined
|
||||
| "ALMOST_EXPIRED"
|
||||
| "EXPIRED" => {
|
||||
const { EXPIRED: expiredDate, ALMOST_EXPIRED: almostExpiredDate } =
|
||||
groupExpiredDates();
|
||||
if (expiredDate.getTime() > data.lastActionAtTimestamp) return "EXPIRED";
|
||||
if (almostExpiredDate.getTime() > data.lastActionAtTimestamp)
|
||||
return "EXPIRED";
|
||||
})();
|
||||
|
||||
if (groupExpirationStatus) {
|
||||
const text =
|
||||
groupExpirationStatus === "EXPIRED"
|
||||
? "Your group has been hidden due to inactivity"
|
||||
: `Without any activity your group will be hidden at ${groupExpiredDates()[
|
||||
"EXPIRED"
|
||||
].toLocaleTimeString("en", { hour: "numeric", minute: "numeric" })}`;
|
||||
return (
|
||||
<Form method="post">
|
||||
<div
|
||||
className={clsx(CONTAINER_CLASSNAME, {
|
||||
expired: groupExpirationStatus === "EXPIRED",
|
||||
})}
|
||||
>
|
||||
{text}. Click{" "}
|
||||
<Button
|
||||
className="play-looking__info-button"
|
||||
variant="minimal"
|
||||
name="_action"
|
||||
value="UNEXPIRE"
|
||||
>
|
||||
here
|
||||
</Button>{" "}
|
||||
if you are still looking.
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={CONTAINER_CLASSNAME}>
|
||||
Last updated:{" "}
|
||||
{lastUpdated.toLocaleTimeString("en", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
app/components/play/UnrankedMatchInfo.tsx
Normal file
36
app/components/play/UnrankedMatchInfo.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Form, useLoaderData } from "remix";
|
||||
import { DISCORD_URL } from "~/constants";
|
||||
import { LookingLoaderData } from "~/routes/play/looking";
|
||||
import { Button } from "../Button";
|
||||
import { GroupCard } from "./GroupCard";
|
||||
|
||||
export function UnrankedMatchInfo() {
|
||||
const data = useLoaderData<LookingLoaderData>();
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="play-looking__waves">
|
||||
<GroupCard group={data.ownGroup} lookingForMatch={false} />
|
||||
<div className="play-looking__waves-text">
|
||||
This is your group! You can reach out to them on{" "}
|
||||
<a href={DISCORD_URL}>our Discord</a> in the #groups-meetup channel.
|
||||
</div>
|
||||
</div>
|
||||
<div className="play-looking__waves-button">
|
||||
<Form method="post">
|
||||
{data.isCaptain && (
|
||||
<Button
|
||||
type="submit"
|
||||
name="_action"
|
||||
value="LOOK_AGAIN"
|
||||
tiny
|
||||
variant="outlined"
|
||||
>
|
||||
Look again
|
||||
</Button>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,3 +73,20 @@ export function groupsToWinningAndLosingPlayerIds({
|
||||
{ winning: [] as string[], losing: [] as string[] }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group dates to compare against for expired status. E.g. if the group
|
||||
* lastActionAt.getTime() is smaller than that of EXPIRED Date's then
|
||||
* that group is expired
|
||||
*/
|
||||
export function groupExpiredDates(): Record<
|
||||
"ALMOST_EXPIRED" | "EXPIRED",
|
||||
Date
|
||||
> {
|
||||
const now = new Date();
|
||||
const thirtyMinutesAgo = new Date(now.getTime() - 60_000 * 30);
|
||||
const now2 = new Date();
|
||||
const twentyMinutesAgo = new Date(now2.getTime() - 60_000 * 20);
|
||||
|
||||
return { EXPIRED: thirtyMinutesAgo, ALMOST_EXPIRED: twentyMinutesAgo };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LfgGroupType } from "@prisma/client";
|
||||
import { generateMapListForLfgMatch } from "~/core/play/mapList";
|
||||
import { groupExpiredDates } from "~/core/play/utils";
|
||||
import { db } from "~/utils/db.server";
|
||||
|
||||
export function create({
|
||||
@@ -35,12 +36,22 @@ export function like({
|
||||
likerId: string;
|
||||
targetId: string;
|
||||
}) {
|
||||
return db.lfgGroupLike.create({
|
||||
data: {
|
||||
likerId,
|
||||
targetId,
|
||||
},
|
||||
});
|
||||
// not transaction because doesn't really matter
|
||||
// if only one goes through and other not
|
||||
return Promise.all([
|
||||
db.lfgGroupLike.create({
|
||||
data: {
|
||||
likerId,
|
||||
targetId,
|
||||
},
|
||||
}),
|
||||
db.lfgGroup.update({
|
||||
where: { id: likerId },
|
||||
data: {
|
||||
lastActionAt: new Date(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export function unlike({
|
||||
@@ -50,14 +61,24 @@ export function unlike({
|
||||
likerId: string;
|
||||
targetId: string;
|
||||
}) {
|
||||
return db.lfgGroupLike.delete({
|
||||
where: {
|
||||
likerId_targetId: {
|
||||
likerId,
|
||||
targetId,
|
||||
// not transaction because doesn't really matter
|
||||
// if only one goes through and other not
|
||||
return Promise.all([
|
||||
db.lfgGroupLike.delete({
|
||||
where: {
|
||||
likerId_targetId: {
|
||||
likerId,
|
||||
targetId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
db.lfgGroup.update({
|
||||
where: { id: likerId },
|
||||
data: {
|
||||
lastActionAt: new Date(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export interface UniteGroupsArgs {
|
||||
@@ -72,7 +93,7 @@ export function uniteGroups({
|
||||
removeCaptainsFromOther,
|
||||
unitedGroupIsRanked,
|
||||
}: UniteGroupsArgs) {
|
||||
const queries = [
|
||||
return db.$transaction([
|
||||
db.lfgGroupMember.updateMany({
|
||||
where: { groupId: otherGroupId },
|
||||
data: {
|
||||
@@ -87,18 +108,14 @@ export function uniteGroups({
|
||||
OR: [{ likerId: survivingGroupId }, { targetId: survivingGroupId }],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
if (typeof unitedGroupIsRanked === "boolean") {
|
||||
queries.push(
|
||||
db.lfgGroup.update({
|
||||
where: { id: survivingGroupId },
|
||||
data: { ranked: unitedGroupIsRanked },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return db.$transaction(queries);
|
||||
db.lfgGroup.update({
|
||||
where: { id: survivingGroupId },
|
||||
data:
|
||||
typeof unitedGroupIsRanked === "boolean"
|
||||
? { ranked: unitedGroupIsRanked, lastActionAt: new Date() }
|
||||
: { lastActionAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function matchUp({
|
||||
@@ -173,6 +190,7 @@ export function findLooking() {
|
||||
id: true,
|
||||
ranked: true,
|
||||
type: true,
|
||||
lastActionAt: true,
|
||||
members: {
|
||||
select: {
|
||||
user: {
|
||||
@@ -225,3 +243,14 @@ export function setInactive(id: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function unexpire(groupId: string) {
|
||||
return db.lfgGroup.update({
|
||||
where: {
|
||||
id: groupId,
|
||||
},
|
||||
data: {
|
||||
lastActionAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,7 +84,6 @@ export const action: ActionFunction = async ({ request, context }) => {
|
||||
|
||||
export const loader: LoaderFunction = async ({ context }) => {
|
||||
const user = getUser(context);
|
||||
// TODO: show something reasonable when user not logged in
|
||||
if (!user) return null;
|
||||
|
||||
const ownGroup = await LFGGroup.findActiveByMember(user);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { LfgGroupType } from "@prisma/client";
|
||||
import {
|
||||
ActionFunction,
|
||||
Form,
|
||||
json,
|
||||
LinksFunction,
|
||||
LoaderFunction,
|
||||
@@ -11,16 +10,17 @@ import {
|
||||
} from "remix";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/Button";
|
||||
import { GroupCard } from "~/components/play/GroupCard";
|
||||
import { LookingInfoText } from "~/components/play/LookingInfoText";
|
||||
import { UnrankedMatchInfo } from "~/components/play/UnrankedMatchInfo";
|
||||
import { Tab } from "~/components/Tab";
|
||||
import { DISCORD_URL, LFG_GROUP_FULL_SIZE } from "~/constants";
|
||||
import { LFG_GROUP_FULL_SIZE } from "~/constants";
|
||||
import {
|
||||
skillToMMR,
|
||||
teamSkillToApproximateMMR,
|
||||
teamSkillToExactMMR,
|
||||
} from "~/core/mmr/utils";
|
||||
import { uniteGroupInfo } from "~/core/play/utils";
|
||||
import { groupExpiredDates, uniteGroupInfo } from "~/core/play/utils";
|
||||
import { canUniteWithGroup, isGroupAdmin } from "~/core/play/validators";
|
||||
import { usePolling } from "~/hooks/common";
|
||||
import * as LFGGroup from "~/models/LFGGroup.server";
|
||||
@@ -63,6 +63,9 @@ const lookingActionSchema = z.union([
|
||||
z.object({
|
||||
_action: z.literal("LOOK_AGAIN"),
|
||||
}),
|
||||
z.object({
|
||||
_action: z.literal("UNEXPIRE"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const action: ActionFunction = async ({ request, context }) => {
|
||||
@@ -150,6 +153,10 @@ export const action: ActionFunction = async ({ request, context }) => {
|
||||
await LFGGroup.setInactive(ownGroup.id);
|
||||
return redirect("/play");
|
||||
}
|
||||
case "UNEXPIRE": {
|
||||
await LFGGroup.unexpire(ownGroup.id);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const exhaustive: never = data;
|
||||
throw new Response(`Unknown action: ${JSON.stringify(exhaustive)}`, {
|
||||
@@ -175,13 +182,14 @@ export type LookingLoaderDataGroup = {
|
||||
ranked?: boolean;
|
||||
};
|
||||
|
||||
interface LookingLoaderData {
|
||||
export interface LookingLoaderData {
|
||||
likedGroups: LookingLoaderDataGroup[];
|
||||
neutralGroups: LookingLoaderDataGroup[];
|
||||
likerGroups: LookingLoaderDataGroup[];
|
||||
ownGroup: LookingLoaderDataGroup;
|
||||
type: LfgGroupType;
|
||||
isCaptain: boolean;
|
||||
lastActionAtTimestamp: number;
|
||||
}
|
||||
|
||||
export const loader: LoaderFunction = async ({ context }) => {
|
||||
@@ -232,10 +240,13 @@ export const loader: LoaderFunction = async ({ context }) => {
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const { EXPIRED: expiredDate } = groupExpiredDates();
|
||||
|
||||
return json<LookingLoaderData>({
|
||||
ownGroup: ownGroupForResponse,
|
||||
type: ownGroup.type,
|
||||
isCaptain: isGroupAdmin({ group: ownGroup, user }),
|
||||
lastActionAtTimestamp: ownGroup.lastActionAt.getTime(),
|
||||
...groups
|
||||
.filter(
|
||||
(group) =>
|
||||
@@ -247,6 +258,7 @@ export const loader: LoaderFunction = async ({ context }) => {
|
||||
})
|
||||
)
|
||||
.filter((group) => group.id !== ownGroup.id)
|
||||
.filter((group) => group.lastActionAt.getTime() > expiredDate.getTime())
|
||||
.map((group) => {
|
||||
const ranked = () => {
|
||||
if (lookingForMatch && !ownGroup.ranked) return false;
|
||||
@@ -280,7 +292,10 @@ export const loader: LoaderFunction = async ({ context }) => {
|
||||
})
|
||||
.reduce(
|
||||
(
|
||||
acc: Omit<LookingLoaderData, "ownGroup" | "type" | "isCaptain">,
|
||||
acc: Omit<
|
||||
LookingLoaderData,
|
||||
"ownGroup" | "type" | "isCaptain" | "lastActionAtTimestamp"
|
||||
>,
|
||||
group
|
||||
) => {
|
||||
// likesReceived first so that if both received like and
|
||||
@@ -307,32 +322,7 @@ export default function LookingPage() {
|
||||
const lastUpdated = usePolling(isPolling);
|
||||
|
||||
if (lookingOver(data.type, data.ownGroup)) {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="play-looking__waves">
|
||||
<GroupCard group={data.ownGroup} lookingForMatch={false} />
|
||||
<div className="play-looking__waves-text">
|
||||
This is your group! You can reach out to them on{" "}
|
||||
<a href={DISCORD_URL}>our Discord</a> in the #groups-meetup channel.
|
||||
</div>
|
||||
</div>
|
||||
<div className="play-looking__waves-button">
|
||||
<Form method="post">
|
||||
{data.isCaptain && (
|
||||
<Button
|
||||
type="submit"
|
||||
name="_action"
|
||||
value="LOOK_AGAIN"
|
||||
tiny
|
||||
variant="outlined"
|
||||
>
|
||||
Look again
|
||||
</Button>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <UnrankedMatchInfo />;
|
||||
}
|
||||
|
||||
const lookingForMatch = data.ownGroup.members?.length === LFG_GROUP_FULL_SIZE;
|
||||
@@ -354,14 +344,7 @@ export default function LookingPage() {
|
||||
ranked={data.ownGroup.ranked}
|
||||
lookingForMatch={false}
|
||||
/>
|
||||
<div className="play-looking__last-updated">
|
||||
Last updated:{" "}
|
||||
{lastUpdated.toLocaleTimeString("en", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
})}
|
||||
</div>
|
||||
<LookingInfoText lastUpdated={lastUpdated} />
|
||||
<hr className="play-looking__divider" />
|
||||
<Tab
|
||||
containerClassName="play-looking__tabs"
|
||||
|
||||
@@ -20,13 +20,21 @@
|
||||
margin-block-start: var(--s-4);
|
||||
}
|
||||
|
||||
.play-looking__last-updated {
|
||||
.play-looking__info-text {
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--semi-bold);
|
||||
margin-block-start: var(--s-4);
|
||||
}
|
||||
|
||||
.play-looking__info-text.expired {
|
||||
color: var(--theme-warning);
|
||||
}
|
||||
|
||||
.play-looking__info-button {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.play-looking__divider {
|
||||
display: none;
|
||||
margin-block-end: var(--s-4);
|
||||
|
||||
@@ -163,6 +163,7 @@ CREATE TABLE "LfgGroup" (
|
||||
"matchId" TEXT,
|
||||
"inviteCode" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastActionAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "LfgGroup_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -223,6 +223,7 @@ model LfgGroup {
|
||||
matchId String?
|
||||
inviteCode String @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
lastActionAt DateTime @default(now())
|
||||
members LfgGroupMember[]
|
||||
match LfgGroupMatch? @relation(fields: [matchId], references: [id])
|
||||
likedGroups LfgGroupLike[] @relation("liker")
|
||||
|
||||
Reference in New Issue
Block a user