Expiring group Closes #733

This commit is contained in:
Kalle
2022-02-18 08:09:17 +02:00
parent 55d4ace226
commit d498456aae
9 changed files with 214 additions and 68 deletions

View 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>
);
}

View 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>
);
}

View File

@@ -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 };
}

View File

@@ -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(),
},
});
}

View File

@@ -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);

View File

@@ -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"

View File

@@ -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);

View File

@@ -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")
);

View File

@@ -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")