Replace trades pages with redirects to Classic DTI

The immediate motivation here is that there's slowness rn, and I'm
digging through the slow query log for suspicious targets (hard to tell
when even normal queries are getting slowed down by whatever the
culprit is!), and Impress 2020's constantly-recalcuated trade activity
dates were one of them.

So, rather than update this page to use the new table columns Classic
now uses, I figured, let's Delete Things and reduce surface area. The
re-merge continues!

If we're lucky, this change alone will stop the bursts of slowness. If
not, we'll keep looking for suspicious targets!
This commit is contained in:
Emi Matchu 2024-09-30 11:48:44 -07:00
parent 35479bf80e
commit 067da33025
5 changed files with 47 additions and 651 deletions

View file

@ -29,6 +29,17 @@ module.exports = {
destination: "/user/:userId/lists",
permanent: true,
},
{
source: "/items/:itemId/trades/offering",
destination:
"https://impress.openneo.net/items/:itemId/trades/offering",
permanent: true,
},
{
source: "/items/:itemId/trades/seeking",
destination: "https://impress.openneo.net/items/:itemId/trades/seeking",
permanent: true,
},
];
},
};

View file

@ -1,54 +0,0 @@
import { GetServerSideProps } from "next";
import { ItemTradesOfferingPage } from "../../../../src/app/ItemTradesPage";
import { gql, loadGraphqlQuery } from "../../../../src/server/ssr-graphql";
// @ts-ignore doesn't understand module.exports
import { oneDay, oneWeek } from "../../../../src/server/util";
export default function ItemTradesOfferingPageWrapper() {
return <ItemTradesOfferingPage />;
}
export const getServerSideProps: GetServerSideProps = async ({
params,
res,
}) => {
if (params?.itemId == null) {
throw new Error(`assertion error: itemId param is missing`);
}
// Load the most important, most stable item data to get onto the page ASAP.
// We'll cache it real hard, to help it load extra-fast for popular items!
const { errors, graphqlState } = await loadGraphqlQuery({
query: gql`
query ItemsTradesOffering_GetServerSideProps($itemId: ID!) {
item(id: $itemId) {
id
name
thumbnailUrl
description
isNc
isPb
createdAt
}
}
`,
variables: { itemId: params.itemId },
});
if (errors) {
console.warn(
`[SSR: /items/[itemId]/trades/offering] Skipping GraphQL preloading, got errors:`
);
for (const error of errors) {
console.warn(`[SSR: /items/[itemId]/trades/offering]`, error);
}
return { props: { graphqlState: {} } };
}
// Cache this very aggressively, because it's such stable data!
res.setHeader(
"Cache-Control",
`public, s-maxage=${oneDay}, stale-while-revalidate=${oneWeek}`
);
return { props: { graphqlState } };
};

View file

@ -1,54 +0,0 @@
import { GetServerSideProps } from "next";
import { ItemTradesSeekingPage } from "../../../../src/app/ItemTradesPage";
import { gql, loadGraphqlQuery } from "../../../../src/server/ssr-graphql";
// @ts-ignore doesn't understand module.exports
import { oneDay, oneWeek } from "../../../../src/server/util";
export default function ItemTradesSeekingPageWrapper() {
return <ItemTradesSeekingPage />;
}
export const getServerSideProps: GetServerSideProps = async ({
params,
res,
}) => {
if (params?.itemId == null) {
throw new Error(`assertion error: itemId param is missing`);
}
// Load the most important, most stable item data to get onto the page ASAP.
// We'll cache it real hard, to help it load extra-fast for popular items!
const { errors, graphqlState } = await loadGraphqlQuery({
query: gql`
query ItemsTradesSeeking_GetServerSideProps($itemId: ID!) {
item(id: $itemId) {
id
name
thumbnailUrl
description
isNc
isPb
createdAt
}
}
`,
variables: { itemId: params.itemId },
});
if (errors) {
console.warn(
`[SSR: /items/[itemId]/trades/seeking] Skipping GraphQL preloading, got errors:`
);
for (const error of errors) {
console.warn(`[SSR: /items/[itemId]/trades/seeking]`, error);
}
return { props: { graphqlState: {} } };
}
// Cache this very aggressively, because it's such stable data!
res.setHeader(
"Cache-Control",
`public, s-maxage=${oneDay}, stale-while-revalidate=${oneWeek}`
);
return { props: { graphqlState } };
};

View file

@ -92,7 +92,7 @@ export function ItemPageContent({ itemId, isEmbedded = false }) {
}
}
`,
{ variables: { itemId }, returnPartialData: true }
{ variables: { itemId }, returnPartialData: true },
);
if (error) {
@ -302,7 +302,7 @@ const ItemPageOwnWantListsDropdownButton = React.forwardRef(
</Flex>
</Flex>
);
}
},
);
function ItemPageOwnWantListsDropdownContent({ closetLists, item }) {
@ -336,7 +336,7 @@ function ItemPageOwnWantsListsDropdownRow({ closetList, item }) {
}
}
`,
{ context: { sendAuth: true } }
{ context: { sendAuth: true } },
);
const [sendRemoveFromListMutation] = useMutation(
@ -352,7 +352,7 @@ function ItemPageOwnWantsListsDropdownRow({ closetList, item }) {
}
}
`,
{ context: { sendAuth: true } }
{ context: { sendAuth: true } },
);
const onChange = React.useCallback(
@ -397,7 +397,13 @@ function ItemPageOwnWantsListsDropdownRow({ closetList, item }) {
});
}
},
[closetList, item, sendAddToListMutation, sendRemoveFromListMutation, toast]
[
closetList,
item,
sendAddToListMutation,
sendRemoveFromListMutation,
toast,
],
);
return (
@ -445,7 +451,7 @@ function ItemPageOwnButton({ itemId, isChecked }) {
context: { sendAuth: true },
},
],
}
},
);
const [sendRemoveMutation] = useMutation(
@ -476,7 +482,7 @@ function ItemPageOwnButton({ itemId, isChecked }) {
context: { sendAuth: true },
},
],
}
},
);
return (
@ -571,7 +577,7 @@ function ItemPageWantButton({ itemId, isChecked }) {
context: { sendAuth: true },
},
],
}
},
);
const [sendRemoveMutation] = useMutation(
@ -602,7 +608,7 @@ function ItemPageWantButton({ itemId, isChecked }) {
context: { sendAuth: true },
},
],
}
},
);
return (
@ -676,7 +682,7 @@ function ItemPageTradeLinks({ itemId, isEmbedded }) {
}
}
`,
{ variables: { itemId } }
{ variables: { itemId } },
);
if (error) {
@ -690,7 +696,7 @@ function ItemPageTradeLinks({ itemId, isEmbedded }) {
</Box>
<SubtleSkeleton isLoaded={!loading}>
<ItemPageTradeLink
href={`/items/${itemId}/trades/offering`}
href={`https://impress.openneo.net/items/${itemId}/trades/offering`}
count={data?.item?.numUsersOfferingThis || 0}
label="offering"
colorScheme="green"
@ -699,7 +705,7 @@ function ItemPageTradeLinks({ itemId, isEmbedded }) {
</SubtleSkeleton>
<SubtleSkeleton isLoaded={!loading}>
<ItemPageTradeLink
href={`/items/${itemId}/trades/seeking`}
href={`https://impress.openneo.net/items/${itemId}/trades/seeking`}
count={data?.item?.numUsersSeekingThis || 0}
label="seeking"
colorScheme="blue"
@ -769,7 +775,7 @@ function IconCheckbox({ icon, isChecked, ...props }) {
function ItemPageOutfitPreview({ itemId }) {
const idealPose = React.useMemo(
() => (Math.random() > 0.5 ? "HAPPY_FEM" : "HAPPY_MASC"),
[]
[],
);
const [petState, setPetState] = React.useState({
// We'll fill these in once the canonical appearance data arrives.
@ -787,11 +793,11 @@ function ItemPageOutfitPreview({ itemId }) {
});
const [preferredSpeciesId, setPreferredSpeciesId] = useLocalStorage(
"DTIItemPreviewPreferredSpeciesId",
null
null,
);
const [preferredColorId, setPreferredColorId] = useLocalStorage(
"DTIItemPreviewPreferredColorId",
null
null,
);
const setPetStateFromUserAction = React.useCallback(
@ -826,7 +832,7 @@ function ItemPageOutfitPreview({ itemId }) {
return newPetState;
}),
[setPreferredColorId, setPreferredSpeciesId]
[setPreferredColorId, setPreferredSpeciesId],
);
// We don't need to reload this query when preferred species/color change, so
@ -845,7 +851,11 @@ function ItemPageOutfitPreview({ itemId }) {
// query after this loads, because our Apollo cache can't detect the
// shared item appearance. (For standard colors though, our logic to
// cover standard-color switches works for this preloading too.)
const { loading: loadingGQL, error: errorGQL, data } = useQuery(
const {
loading: loadingGQL,
error: errorGQL,
data,
} = useQuery(
gql`
query ItemPageOutfitPreview(
$itemId: ID!
@ -920,7 +930,7 @@ function ItemPageOutfitPreview({ itemId }) {
appearanceId: canonicalPetAppearance?.id,
});
},
}
},
);
const compatibleBodies =
@ -936,7 +946,7 @@ function ItemPageOutfitPreview({ itemId }) {
compatibleBodies.length === 1 &&
!compatibleBodies[0].representsAllBodies &&
(data?.item?.name || "").includes(
data?.item?.canonicalAppearance?.body?.canonicalAppearance?.species?.name
data?.item?.canonicalAppearance?.body?.canonicalAppearance?.species?.name,
);
const couldProbablyModelMoreData = !isProbablySpeciesSpecific;
@ -982,7 +992,7 @@ function ItemPageOutfitPreview({ itemId }) {
appearanceId: null,
});
},
[valids, idealPose, setPetStateFromUserAction]
[valids, idealPose, setPetStateFromUserAction],
);
const borderColor = useColorModeValue("green.700", "green.400");
@ -1201,8 +1211,8 @@ function ExpandOnGroupHover({ children, ...props }) {
// I don't think this is possible, but I'd like to know if it happens!
logAndCapture(
new Error(
`Measurer node not ready during effect. Transition won't be smooth.`
)
`Measurer node not ready during effect. Transition won't be smooth.`,
),
);
return;
}
@ -1283,8 +1293,8 @@ export function ItemZonesInfo({
const sortedZonesAndTheirBodies = [...zoneLabelsAndTheirBodies].sort((a, b) =>
buildSortKeyForZoneLabelsAndTheirBodies(a).localeCompare(
buildSortKeyForZoneLabelsAndTheirBodies(b)
)
buildSortKeyForZoneLabelsAndTheirBodies(b),
),
);
const restrictedZoneLabels = [
@ -1296,8 +1306,8 @@ export function ItemZonesInfo({
// preview available in the list has the zones listed here.
const bodyGroups = new Set(
zoneLabelsAndTheirBodies.map(({ bodies }) =>
bodies.map((b) => b.id).join(",")
)
bodies.map((b) => b.id).join(","),
),
);
const showBodyInfo = bodyGroups.size > 1;

View file

@ -1,517 +0,0 @@
import React from "react";
import { ClassNames } from "@emotion/react";
import {
Box,
Button,
Flex,
Skeleton,
useColorModeValue,
useToken,
} from "@chakra-ui/react";
import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
import Link from "next/link";
import { useRouter } from "next/router";
import { Heading2 } from "./util";
import ItemPageLayout from "./ItemPageLayout";
import useCurrentUser from "./components/useCurrentUser";
import { ChevronDownIcon, ChevronUpIcon } from "@chakra-ui/icons";
import Head from "next/head";
export function ItemTradesOfferingPage() {
return (
<ItemTradesPage
title="Trades: Offering"
userHeading="Owner"
compareColumnLabel="Trade for your…"
tradesQuery={gql`
query ItemTradesTableOffering($itemId: ID!) {
item(id: $itemId) {
id
trades: tradesOffering {
id
user {
id
username
lastTradeActivity
matchingItems: itemsTheyWantThatCurrentUserOwns {
id
name
}
}
closetList {
id
name
}
}
}
}
`}
/>
);
}
export function ItemTradesSeekingPage() {
return (
<ItemTradesPage
title="Trades: Seeking"
userHeading="Seeker"
compareColumnLabel="Trade for their…"
tradesQuery={gql`
query ItemTradesTableSeeking($itemId: ID!) {
item(id: $itemId) {
id
trades: tradesSeeking {
id
user {
id
username
lastTradeActivity
matchingItems: itemsTheyOwnThatCurrentUserWants {
id
name
}
}
closetList {
id
name
}
}
}
}
`}
/>
);
}
function ItemTradesPage({
title,
userHeading,
compareColumnLabel,
tradesQuery,
}) {
const { query } = useRouter();
const { itemId } = query;
const { error, data } = useQuery(
gql`
query ItemTradesPage($itemId: ID!) {
item(id: $itemId) {
id
name
isNc
isPb
thumbnailUrl
description
createdAt
ncTradeValueText
}
}
`,
{ variables: { itemId }, returnPartialData: true }
);
if (error) {
return <Box color="red.400">{error.message}</Box>;
}
return (
<>
<Head>
{data?.item?.name && (
<title>
{data?.item?.name} | {title} | Dress to Impress
</title>
)}
</Head>
<ItemPageLayout item={data?.item}>
<Heading2 marginTop="6" marginBottom="4">
{title}
</Heading2>
<ItemTradesTable
itemId={itemId}
userHeading={userHeading}
compareColumnLabel={compareColumnLabel}
tradesQuery={tradesQuery}
/>
</ItemPageLayout>
</>
);
}
function ItemTradesTable({
itemId,
userHeading,
compareColumnLabel,
tradesQuery,
}) {
const { isLoggedIn } = useCurrentUser();
const { loading, error, data } = useQuery(tradesQuery, {
variables: { itemId },
context: { sendAuth: true },
});
const [isShowingInactiveTrades, setIsShowingInactiveTrades] = React.useState(
false
);
const shouldShowCompareColumn = isLoggedIn;
// We partially randomize trade sorting, but we want it to stay stable across
// re-renders. To do this, we can use `getTradeSortKey`, which will either
// build a new sort key for the trade, or return the cached one from the
// `tradeSortKeys` map.
const tradeSortKeys = React.useMemo(() => new Map(), []);
const getTradeSortKey = (trade) => {
if (!tradeSortKeys.has(trade.id)) {
tradeSortKeys.set(
trade.id,
getVaguelyRandomizedTradeSortKey(
trade.user.lastTradeActivity,
trade.user.matchingItems.length
)
);
}
return tradeSortKeys.get(trade.id);
};
const allTrades = [...(data?.item?.trades || [])];
// Only trades from users active within the last 6 months are shown by
// default. The user can toggle to the full view, though!
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
const activeTrades = allTrades.filter(
(t) => new Date(t.user.lastTradeActivity) > sixMonthsAgo
);
const trades = isShowingInactiveTrades ? allTrades : activeTrades;
trades.sort((a, b) => getTradeSortKey(b).localeCompare(getTradeSortKey(a)));
const numInactiveTrades = allTrades.length - activeTrades.length;
if (error) {
return <Box color="red.400">{error.message}</Box>;
}
const minorColumnWidth = {
base: shouldShowCompareColumn ? "23%" : "30%",
md: "20ex",
};
return (
<ClassNames>
{({ css }) => (
<Box>
<Box
as="table"
width="100%"
boxShadow="md"
className={css`
/* Chakra doesn't have props for these! */
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
`}
>
<Box as="thead" fontSize={{ base: "xs", sm: "sm" }}>
<Box as="tr">
<ItemTradesTableCell as="th" width={minorColumnWidth}>
{/* A small wording tweak to fit better on the xsmall screens! */}
<Box display={{ base: "none", sm: "block" }}>Last active</Box>
<Box display={{ base: "block", sm: "none" }}>Last edit</Box>
</ItemTradesTableCell>
{shouldShowCompareColumn && (
<ItemTradesTableCell as="th" width={minorColumnWidth}>
<Box display={{ base: "none", sm: "block" }}>
{compareColumnLabel}
</Box>
<Box display={{ base: "block", sm: "none" }}>Matches</Box>
</ItemTradesTableCell>
)}
<ItemTradesTableCell as="th" width={minorColumnWidth}>
{userHeading}
</ItemTradesTableCell>
<ItemTradesTableCell as="th">List</ItemTradesTableCell>
</Box>
</Box>
<Box as="tbody">
{loading && (
<>
<ItemTradesTableRowSkeleton
shouldShowCompareColumn={shouldShowCompareColumn}
/>
<ItemTradesTableRowSkeleton
shouldShowCompareColumn={shouldShowCompareColumn}
/>
<ItemTradesTableRowSkeleton
shouldShowCompareColumn={shouldShowCompareColumn}
/>
<ItemTradesTableRowSkeleton
shouldShowCompareColumn={shouldShowCompareColumn}
/>
<ItemTradesTableRowSkeleton
shouldShowCompareColumn={shouldShowCompareColumn}
/>
</>
)}
{!loading &&
trades.length > 0 &&
trades.map((trade) => (
<ItemTradesTableRow
key={trade.id}
href={`/user/${trade.user.id}/lists#list-${trade.closetList.id}`}
username={trade.user.username}
listName={trade.closetList.name}
lastTradeActivity={trade.user.lastTradeActivity}
matchingItems={trade.user.matchingItems}
shouldShowCompareColumn={shouldShowCompareColumn}
/>
))}
{!loading && trades.length === 0 && (
<Box as="tr">
<ItemTradesTableCell
colSpan={shouldShowCompareColumn ? 4 : 3}
textAlign="center"
fontStyle="italic"
>
No trades yet!
</ItemTradesTableCell>
</Box>
)}
</Box>
</Box>
{numInactiveTrades > 0 && (
<Flex justify="center">
<Button
size="sm"
variant="outline"
marginTop="4"
onClick={() => setIsShowingInactiveTrades((s) => !s)}
>
{isShowingInactiveTrades ? (
<>
<ChevronUpIcon marginRight="2" />
Hide {numInactiveTrades} older trades
<ChevronUpIcon marginLeft="2" />
</>
) : (
<>
<ChevronDownIcon marginRight="2" />
Show {numInactiveTrades} more older trades
<ChevronDownIcon marginLeft="2" />
</>
)}
</Button>
</Flex>
)}
</Box>
)}
</ClassNames>
);
}
function ItemTradesTableRow({
href,
username,
listName,
lastTradeActivity,
matchingItems,
shouldShowCompareColumn,
}) {
const { push: pushHistory } = useRouter();
const onClick = React.useCallback(() => pushHistory(href), [
pushHistory,
href,
]);
const focusBackground = useColorModeValue("gray.100", "gray.600");
const sortedMatchingItems = [...matchingItems].sort((a, b) =>
a.name.localeCompare(b.name)
);
return (
<ClassNames>
{({ css }) => (
<Box
as="tr"
cursor="pointer"
_hover={{ background: focusBackground }}
_focusWithin={{ background: focusBackground }}
onClick={onClick}
>
<ItemTradesTableCell fontSize="xs">
{formatVagueDate(lastTradeActivity)}
</ItemTradesTableCell>
{shouldShowCompareColumn && (
<ItemTradesTableCell fontSize="xs">
{matchingItems.length > 0 ? (
<Box as="ul">
{sortedMatchingItems.slice(0, 4).map((item) => (
<Box key={item.id} as="li">
<Box
lineHeight="1.5"
maxHeight="1.5em"
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{item.name}
</Box>
</Box>
))}
{matchingItems.length > 4 && (
<Box as="li">+ {matchingItems.length - 4} more</Box>
)}
</Box>
) : (
<>
<Box display={{ base: "none", sm: "block" }}>No matches</Box>
<Box display={{ base: "block", sm: "none" }}>None</Box>
</>
)}
</ItemTradesTableCell>
)}
<ItemTradesTableCell fontSize="xs">{username}</ItemTradesTableCell>
<ItemTradesTableCell fontSize="sm">
<Link href={href} passHref>
<Box
as="a"
className={css`
&:hover,
&:focus,
tr:hover &,
tr:focus-within & {
text-decoration: underline;
}
`}
>
{listName}
</Box>
</Link>
</ItemTradesTableCell>
</Box>
)}
</ClassNames>
);
}
function ItemTradesTableRowSkeleton({ shouldShowCompareColumn }) {
return (
<Box as="tr">
<ItemTradesTableCell>
<Skeleton width="100%">X</Skeleton>
</ItemTradesTableCell>
<ItemTradesTableCell>
<Skeleton width="100%">X</Skeleton>
</ItemTradesTableCell>
<ItemTradesTableCell>
<Skeleton width="100%">X</Skeleton>
</ItemTradesTableCell>
{shouldShowCompareColumn && (
<ItemTradesTableCell>
<Skeleton width="100%">X</Skeleton>
</ItemTradesTableCell>
)}
</Box>
);
}
function ItemTradesTableCell({ children, as = "td", ...props }) {
const borderColor = useColorModeValue("gray.300", "gray.400");
const borderColorCss = useToken("colors", borderColor);
const borderRadiusCss = useToken("radii", "md");
return (
<ClassNames>
{({ css }) => (
<Box
as={as}
paddingX="4"
paddingY="2"
textAlign="left"
className={css`
/* Lol sigh, getting this right is way more involved than I wish it
* were. What I really want is border-collapse and a simple 1px border,
* but that disables border-radius. So, we homebrew it by giving all
* cells bottom and right borders, but only the cells on the edges a
* top or left border; and then target the exact 4 corner cells to
* round them. Pretty old-school tbh 🙃 */
border-bottom: 1px solid ${borderColorCss};
border-right: 1px solid ${borderColorCss};
thead tr:first-of-type & {
border-top: 1px solid ${borderColorCss};
}
&:first-of-type {
border-left: 1px solid ${borderColorCss};
}
thead tr:first-of-type &:first-of-type {
border-top-left-radius: ${borderRadiusCss};
}
thead tr:first-of-type &:last-of-type {
border-top-right-radius: ${borderRadiusCss};
}
tbody tr:last-of-type &:first-of-type {
border-bottom-left-radius: ${borderRadiusCss};
}
tbody tr:last-of-type &:last-of-type {
border-bottom-right-radius: ${borderRadiusCss};
}
`}
{...props}
>
{children}
</Box>
)}
</ClassNames>
);
}
function isThisWeek(date) {
const startOfThisWeek = new Date();
startOfThisWeek.setDate(startOfThisWeek.getDate() - 7);
return date > startOfThisWeek;
}
const shortMonthYearFormatter = new Intl.DateTimeFormat("en", {
month: "short",
year: "numeric",
});
function formatVagueDate(dateString) {
const date = new Date(dateString);
if (isThisWeek(date)) {
return "This week";
}
return shortMonthYearFormatter.format(date);
}
function getVaguelyRandomizedTradeSortKey(dateString, numMatchingItems) {
const date = new Date(dateString);
const hasMatchingItems = numMatchingItems >= 1;
// "This week" sorts after all other dates, but with a random factor! I don't
// want people worrying about gaming themselves up to the very top, just be
// active and trust the system 😅 (I figure that, if you care enough to "game"
// the system by faking activity every week, you probably also care enough to
// be... making real trades every week lmao)
//
// We also prioritize having matches, but we don't bother to sort _how many_
// matches, to decrease the power of gaming with large honeypot lists, and
// because it's hard to judge how good matches are anyway.
if (isThisWeek(date)) {
const matchingItemsKey = hasMatchingItems
? "ZZmatchingZZ"
: "AAnotmatchingAA";
return `ZZZthisweekZZZ-${matchingItemsKey}-${Math.random()}`;
}
return dateString;
}