Merge branch 'owls-integration' into main

This commit is contained in:
Emi Matchu 2022-08-16 00:16:09 -07:00
commit 38957c50c0
6 changed files with 224 additions and 93 deletions

View file

@ -0,0 +1,127 @@
const beeline = require("honeycomb-beeline")({
writeKey: process.env["HONEYCOMB_WRITE_KEY"],
dataset:
process.env["NODE_ENV"] === "production"
? "Dress to Impress (2020)"
: "Dress to Impress (2020, dev)",
serviceName: "impress-2020-gql-server",
});
import fetch from "node-fetch";
import connectToDb from "../../src/server/db";
async function handle(req, res) {
const allNcItemNamesAndIdsPromise = loadAllNcItemNamesAndIds();
let itemValuesByIdOrName;
try {
itemValuesByIdOrName = await loadOWLSValuesByIdOrName();
} catch (e) {
console.error(e);
res.setHeader("Content-Type", "text/plain; charset=utf8");
res.status(500).send("Error loading OWLS Pricer data");
return;
}
// Restructure the value data to use IDs as keys, instead of names.
const allNcItemNamesAndIds = await allNcItemNamesAndIdsPromise;
const itemValues = {};
for (const { name, id } of allNcItemNamesAndIds) {
if (id in itemValuesByIdOrName) {
itemValues[id] = itemValuesByIdOrName[id];
} else if (name in itemValuesByIdOrName) {
itemValues[id] = itemValuesByIdOrName[name];
}
}
// Cache for 1 minute, and immediately serve stale data for a day after.
// This should keep it fast and responsive, and stay well within our API key
// limits. (This will cause the client to send more requests than necessary,
// but the CDN cache should generally respond quickly with a small 304 Not
// Modified, unless the data really did change.)
res.setHeader(
"Cache-Control",
"public, max-age=3600, stale-while-revalidate=86400"
);
return res.send(itemValues);
}
async function loadAllNcItemNamesAndIds() {
const db = await connectToDb();
const [rows] = await db.query(`
SELECT items.id, item_translations.name FROM items
INNER JOIN item_translations ON item_translations.item_id = items.id
WHERE
(items.rarity_index IN (0, 500) OR is_manually_nc = 1)
AND item_translations.locale = "en"
`);
return rows.map(({ id, name }) => ({ id, name: normalizeItemName(name) }));
}
/**
* Load all OWLS Pricer values from the spreadsheet. Returns an object keyed by
* ID or name - that is, if the item ID is provided, we use that as the key; or
* if not, we use the name as the key.
*/
async function loadOWLSValuesByIdOrName() {
const res = await fetch(
`https://neo-owls.herokuapp.com/itemdata/owls_script/`
);
const json = await res.json();
if (!res.ok) {
throw new Error(
`Could not load OWLS Pricer data: ${res.status} ${res.statusText}`
);
}
const itemValuesByIdOrName = {};
for (const [itemName, valueText] of Object.entries(json)) {
// OWLS returns an empty string for NC Mall items they don't have a trade
// value for, to allow the script to distinguish between NP items vs
// no-data NC items. We omit it from our data instead, because our UI is
// already aware of whether the item is NP or NC.
if (valueText.trim() === "") {
continue;
}
// TODO: OWLS doesn't currently provide item IDs ever. Add support for it
// if it does! (I'm keeping the rest of the code the same because I
// think that might happen for disambiguation, like Waka did.) Until
// then, we just always key by name.
const normalizedItemName = normalizeItemName(itemName);
// We wrap it in an object with the key `valueText`, just to not break
// potential external consumers of this endpoint if we add more fields.
// (This is kinda silly and unnecessary, but it should get gzipped out and
// shouldn't add substantial time to building or parsing, so like w/e!)
itemValuesByIdOrName[normalizedItemName] = { valueText };
}
return itemValuesByIdOrName;
}
function normalizeItemName(name) {
return (
name
// Remove all spaces, they're a common source of inconsistency
.replace(/\s+/g, "")
// Lower case, because capitalization is another common source
.toLowerCase()
// Remove diacritics: https://stackoverflow.com/a/37511463/107415
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
);
}
async function handleWithBeeline(req, res) {
beeline.withTrace(
{ name: "api/allNCTradeValues", operation_name: "api/allNCTradeValues" },
() => handle(req, res)
);
}
export default handleWithBeeline;

View file

@ -36,7 +36,13 @@ import { useQuery, useMutation } from "@apollo/client";
import { Link, useParams } from "react-router-dom";
import ItemPageLayout, { SubtleSkeleton } from "./ItemPageLayout";
import { Delay, logAndCapture, useLocalStorage, usePageTitle } from "./util";
import {
Delay,
logAndCapture,
MajorErrorMessage,
useLocalStorage,
usePageTitle,
} from "./util";
import HTML5Badge, { layerUsesHTML5 } from "./components/HTML5Badge";
import {
itemAppearanceFragment,
@ -77,7 +83,7 @@ export function ItemPageContent({ itemId, isEmbedded }) {
thumbnailUrl
description
createdAt
wakaValueText
ncTradeValueText
# For Support users.
rarityIndex
@ -91,7 +97,7 @@ export function ItemPageContent({ itemId, isEmbedded }) {
usePageTitle(data?.item?.name, { skip: isEmbedded });
if (error) {
return <Box color="red.400">{error.message}</Box>;
return <MajorErrorMessage error={error} />;
}
const item = data?.item;

View file

@ -5,7 +5,6 @@ import {
Flex,
Popover,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverTrigger,
Portal,
@ -16,12 +15,7 @@ import {
useToast,
VStack,
} from "@chakra-ui/react";
import {
ExternalLinkIcon,
ChevronRightIcon,
QuestionIcon,
WarningTwoIcon,
} from "@chakra-ui/icons";
import { ExternalLinkIcon, ChevronRightIcon } from "@chakra-ui/icons";
import { gql, useMutation } from "@apollo/client";
import {
@ -111,6 +105,7 @@ export function SubtleSkeleton({ isLoaded, ...props }) {
function ItemPageBadges({ item, isEmbedded }) {
const searchBadgesAreLoaded = item?.name != null && item?.isNc != null;
const shouldShowOwls = useShouldShowOwls();
return (
<ItemBadgeList marginTop="1">
@ -156,47 +151,18 @@ function ItemPageBadges({ item, isEmbedded }) {
Jellyneo
</LinkBadge>
</SubtleSkeleton>
{item.isNc && (
{item.isNc && shouldShowOwls && (
<SubtleSkeleton
isLoaded={
// Distinguish between undefined (still loading) and null (loaded and
// empty).
item.wakaValueText !== undefined
// Distinguish between undefined (still loading) and null (loaded
// and empty).
item.ncTradeValueText !== undefined
}
>
{shouldShowWaka() && item.wakaValueText && (
<>
{/* For hover-y devices, use a hover popover over the badge. */}
<Box sx={{ "@media (hover: none)": { display: "none" } }}>
<WakaPopover trigger="hover">
<LinkBadge
href="http://www.neopets.com/~waka"
colorScheme="yellow"
>
<WarningTwoIcon marginRight="1" />
Waka: {item.wakaValueText}
</LinkBadge>
</WakaPopover>
</Box>
{/* For touch-y devices, use a tappable help icon. */}
<Flex
sx={{ "@media (hover: hover)": { display: "none" } }}
align="center"
>
<LinkBadge
href="http://www.neopets.com/~waka"
colorScheme="yellow"
>
<WarningTwoIcon marginRight="1" />
Waka: {item.wakaValueText}
</LinkBadge>
<WakaPopover>
<Flex align="center" fontSize="sm" paddingX="2" tabIndex="0">
<QuestionIcon />
</Flex>
</WakaPopover>
</Flex>
</>
{item.ncTradeValueText && (
<LinkBadge href="http://www.neopets.com/~owls">
OWLS: {item.ncTradeValueText}
</LinkBadge>
)}
</SubtleSkeleton>
)}
@ -439,55 +405,34 @@ function ShortTimestamp({ when }) {
);
}
function WakaPopover({ children, ...props }) {
return (
<Popover placement="bottom" {...props}>
<PopoverTrigger>{children}</PopoverTrigger>
<Portal>
<PopoverContent>
<PopoverArrow />
<PopoverBody fontSize="sm">
<p>
<strong>
The Waka Guide for NC trade values is closing down!
</strong>{" "}
We're sad to see them go, but excited that the team gets to move
onto the next phase in their life 💖
</p>
<Box height="1em" />
<p>
The Waka guide was last updated August 7, 2021, so this value
might not be accurate anymore. Consider checking in with the
Neoboards!
</p>
<Box height="1em" />
<p>
Thanks again to the Waka team for letting us experiment with
sharing their trade values here. Best wishes for everything to
come! 💜
</p>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
}
// August 21, 2021. (The month uses 0-indexing, but nothing else does! 🙃)
const STOP_SHOWING_WAKA_AFTER = new Date(2021, 7, 21, 0, 0, 0, 0);
const SHOULD_SHOW_OWLS = false;
/**
* shouldShowWaka returns true if, according to the browser, it's not yet
* August 21, 2021. It starts returning false at midnight on Aug 21.
* useShouldShowOwls will return false until the user types "~owls" on the
* page, after which the ~owls badge will appear.
*
* That way, our Waka deprecation message is on an auto-timer. After Aug 21,
* it's safe to remove all Waka UI code, and the Waka API endpoint and GraphQL
* fields. (It might be kind to return a placeholder string for the GraphQL
* case!)
* We also keep the value in a global, so it'll stick if you go search for
* another item too!
*/
function shouldShowWaka() {
const now = new Date();
return now < STOP_SHOWING_WAKA_AFTER;
function useShouldShowOwls() {
const [mostRecentKeys, setMostRecentKeys] = React.useState([]);
const [shouldShowOwls, setShouldShowOwls] = React.useState(SHOULD_SHOW_OWLS);
React.useEffect(() => {
const onKeyPress = (e) => {
const newMostRecentKeys = [...mostRecentKeys, e.key].slice(-5);
if (newMostRecentKeys.join("") === "~owls") {
SHOULD_SHOW_OWLS = true;
setShouldShowOwls(true);
}
setMostRecentKeys(newMostRecentKeys);
};
window.addEventListener("keypress", onKeyPress);
return () => window.removeEventListener("keypress", onKeyPress);
});
return shouldShowOwls;
}
export default ItemPageLayout;

View file

@ -102,7 +102,7 @@ function ItemTradesPage({
thumbnailUrl
description
createdAt
wakaValueText
ncTradeValueText
}
}
`,

View file

@ -1,4 +1,5 @@
import DataLoader from "dataloader";
import fetch from "node-fetch";
import { normalizeRow } from "./util";
const buildClosetListLoader = (db) =>
@ -825,6 +826,31 @@ const buildItemTradesLoader = (db, loaders) =>
{ cacheKeyFn: ({ itemId, isOwned }) => `${itemId}-${isOwned}` }
);
const buildItemNCTradeValueLoader = () =>
new DataLoader(async (itemIds) => {
// This loader calls our /api/allNCTradeValues endpoint, to take advantage
// of the CDN caching. This helps us respond a bit faster than calling the
// API directly would, and avoids putting network pressure or caching
// complexity on our ~owls friends! (It would also be pretty reasonable to
// do this as a process-level cache or something instead, but I'm reusing
// Waka code from when we were on a more distributed system where that
// wouldn't have worked out, and I don't think the effort to refactor this
// just for the potential perf win is worthy!)
const url = process.env.NODE_ENV === "production"
? "https://impress-2020.openneo.net/api/allNCTradeValues"
: "http://localhost:3000/api/allNCTradeValues";
const res = await fetch(url);
if (!res.ok) {
throw new Error(
`Error loading /api/allNCTradeValues: ${res.status} ${res.statusText}`
);
}
const allNCTradeValues = await res.json();
return itemIds.map((itemId) => allNCTradeValues[itemId]);
});
const buildPetTypeLoader = (db, loaders) =>
new DataLoader(async (petTypeIds) => {
const qs = petTypeIds.map((_) => "?").join(",");
@ -1495,6 +1521,7 @@ function buildLoaders(db) {
db
);
loaders.itemTradesLoader = buildItemTradesLoader(db, loaders);
loaders.itemNCTradeValueLoader = buildItemNCTradeValueLoader();
loaders.petTypeLoader = buildPetTypeLoader(db, loaders);
loaders.petTypeBySpeciesAndColorLoader = buildPetTypeBySpeciesAndColorLoader(
db,

View file

@ -33,6 +33,18 @@ const typeDefs = gql`
"""
wakaValueText: String @cacheControl(maxAge: ${oneHour})
"""
This item's NC trade value as a human-readable string. Returns null if the
value is not known.
Note that the format of this string is not well-specifiedit's fully
human-curated and may include surprising words or extra notes! We recommend
presenting the text exactly as-is, rather than trying to parse and math it.
This data is currently curated by neopets.com/~owls, thank you!! <3
"""
ncTradeValueText: String @cacheControl(maxAge: ${oneHour})
currentUserOwnsThis: Boolean! @cacheControl(maxAge: 0, scope: PRIVATE)
currentUserWantsThis: Boolean! @cacheControl(maxAge: 0, scope: PRIVATE)
@ -344,6 +356,20 @@ const resolvers = {
// This feature is deprecated, so now we just always return unknown value.
return null;
},
ncTradeValueText: async ({ id }, _, { itemNCTradeValueLoader }) => {
let ncTradeValue;
try {
ncTradeValue = await itemNCTradeValueLoader.load(id);
} catch (e) {
console.error(
`Error loading ncTradeValueText for item ${id}, skipping:`
);
console.error(e);
ncTradeValue = null;
}
return ncTradeValue ? ncTradeValue.valueText : null;
},
currentUserOwnsThis: async (
{ id },