diff --git a/app/components/play/LookingInfoText.tsx b/app/components/play/LookingInfoText.tsx new file mode 100644 index 000000000..2cbe64521 --- /dev/null +++ b/app/components/play/LookingInfoText.tsx @@ -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(); + + 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 ( +
+
+ {text}. Click{" "} + {" "} + if you are still looking. +
+
+ ); + } + + return ( +
+ Last updated:{" "} + {lastUpdated.toLocaleTimeString("en", { + hour: "numeric", + minute: "numeric", + second: "numeric", + })} +
+ ); +} diff --git a/app/components/play/UnrankedMatchInfo.tsx b/app/components/play/UnrankedMatchInfo.tsx new file mode 100644 index 000000000..432b31525 --- /dev/null +++ b/app/components/play/UnrankedMatchInfo.tsx @@ -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(); + + return ( +
+
+ +
+ This is your group! You can reach out to them on{" "} + our Discord in the #groups-meetup channel. +
+
+
+
+ {data.isCaptain && ( + + )} +
+
+
+ ); +} diff --git a/app/core/play/utils.ts b/app/core/play/utils.ts index d09870682..bafa18b29 100644 --- a/app/core/play/utils.ts +++ b/app/core/play/utils.ts @@ -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 }; +} diff --git a/app/models/LFGGroup.server.ts b/app/models/LFGGroup.server.ts index 834002550..f0c99861e 100644 --- a/app/models/LFGGroup.server.ts +++ b/app/models/LFGGroup.server.ts @@ -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(), + }, + }); +} diff --git a/app/routes/play/index.tsx b/app/routes/play/index.tsx index 7843066fb..fa55155ff 100644 --- a/app/routes/play/index.tsx +++ b/app/routes/play/index.tsx @@ -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); diff --git a/app/routes/play/looking.tsx b/app/routes/play/looking.tsx index 4c31d89fe..b988739a7 100644 --- a/app/routes/play/looking.tsx +++ b/app/routes/play/looking.tsx @@ -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({ 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, + 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 ( -
-
- -
- This is your group! You can reach out to them on{" "} - our Discord in the #groups-meetup channel. -
-
-
-
- {data.isCaptain && ( - - )} -
-
-
- ); + return ; } const lookingForMatch = data.ownGroup.members?.length === LFG_GROUP_FULL_SIZE; @@ -354,14 +344,7 @@ export default function LookingPage() { ranked={data.ownGroup.ranked} lookingForMatch={false} /> -
- Last updated:{" "} - {lastUpdated.toLocaleTimeString("en", { - hour: "numeric", - minute: "numeric", - second: "numeric", - })} -
+