Merge pull request #71 from quantified-uncertainty/question-pages

Fix for upserts, question pages (WIP), history
This commit is contained in:
Vyacheslav Matyukhin 2022-04-27 22:01:49 +03:00 committed by GitHub
commit 4d3918629f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 764 additions and 540 deletions

View File

@ -24,6 +24,14 @@ List of all files used for graphql:
`graphql-code-generator` converts those into `*.generated.ts` files which can be imported from the React components. `graphql-code-generator` converts those into `*.generated.ts` files which can be imported from the React components.
# Notes on caching
`urql` has both [document caching](https://formidable.com/open-source/urql/docs/basics/document-caching/) and [normalized caching](https://formidable.com/open-source/urql/docs/graphcache/normalized-caching/) (which we don't use yet).
Unfortunately, it's useful only on a page level: since we like server-side rendering, we still have to hit `getServerSideProps` on navigation, even if we have data in cache.
There are some possible workaround for this to make client-side navigation faster, but none of them are trivial to implement; relevant Next.js discussion to follow: https://github.com/vercel/next.js/discussions/19611
# Recipes # Recipes
**I want to check out what Metaforecast's GraphQL API is capable of** **I want to check out what Metaforecast's GraphQL API is capable of**

View File

@ -0,0 +1,5 @@
ALTER TABLE "history" ADD COLUMN "idref" TEXT;
ALTER TABLE "history" ADD CONSTRAINT "history_idref_fkey" FOREIGN KEY ("idref") REFERENCES "questions"("id") ON DELETE SET NULL ON UPDATE RESTRICT;
UPDATE "history" SET idref = id WHERE id in (SELECT id FROM "questions");

View File

@ -1,5 +1,6 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
previewFeatures = ["interactiveTransactions"]
} }
generator pothos { generator pothos {
@ -25,6 +26,8 @@ model Dashboard {
model History { model History {
id String id String
idref String?
question Question? @relation(fields: [idref], references: [id], onDelete: SetNull, onUpdate: Restrict)
title String title String
url String url String
platform String platform String
@ -75,6 +78,8 @@ model Question {
extra Json extra Json
onFrontpage FrontpageId? onFrontpage FrontpageId?
history History[]
@@map("questions") @@map("questions")
} }

View File

@ -93,6 +93,9 @@ type Query {
"""Get a list of questions that are currently on the frontpage""" """Get a list of questions that are currently on the frontpage"""
frontpage: [Question!]! frontpage: [Question!]!
"""Look up a single question by its id"""
question(id: ID!): Question!
questions(after: String, before: String, first: Int, last: Int): QueryQuestionsConnection! questions(after: String, before: String, first: Int, last: Int): QueryQuestionsConnection!
""" """

View File

@ -3,6 +3,9 @@ import { prisma } from "../../database/prisma";
export async function updateHistory() { export async function updateHistory() {
const questions = await prisma.question.findMany({}); const questions = await prisma.question.findMany({});
await prisma.history.createMany({ await prisma.history.createMany({
data: questions, data: questions.map((q) => ({
...q,
idref: q.id,
})),
}); });
} }

View File

@ -89,27 +89,69 @@ export const processPlatform = async (platform: Platform) => {
console.log(`Platform ${platform.name} doesn't have a fetcher, skipping`); console.log(`Platform ${platform.name} doesn't have a fetcher, skipping`);
return; return;
} }
const results = await platform.fetcher(); const fetchedQuestions = await platform.fetcher();
if (results && results.length) { if (!fetchedQuestions || !fetchedQuestions.length) {
await prisma.$transaction([ console.log(`Platform ${platform.name} didn't return any results`);
prisma.question.deleteMany({ return;
where: { }
platform: platform.name,
}, const prepareQuestion = (q: FetchedQuestion): Question => {
}), return {
prisma.question.createMany({
data: results.map((q) => ({
extra: {}, extra: {},
timestamp: new Date(), timestamp: new Date(),
...q, ...q,
platform: platform.name,
qualityindicators: q.qualityindicators as object, // fighting typescript qualityindicators: q.qualityindicators as object, // fighting typescript
})), };
}), };
]);
console.log("Done"); const oldQuestions = await prisma.question.findMany({
where: {
platform: platform.name,
},
});
const fetchedIds = fetchedQuestions.map((q) => q.id);
const oldIds = oldQuestions.map((q) => q.id);
const fetchedIdsSet = new Set(fetchedIds);
const oldIdsSet = new Set(oldIds);
const createdQuestions: Question[] = [];
const updatedQuestions: Question[] = [];
const deletedIds = oldIds.filter((id) => !fetchedIdsSet.has(id));
for (const q of fetchedQuestions.map((q) => prepareQuestion(q))) {
if (oldIdsSet.has(q.id)) {
updatedQuestions.push(q);
} else { } else {
console.log(`Platform ${platform.name} didn't return any results`); // TODO - check if question has changed for better performance
createdQuestions.push(q);
} }
}
await prisma.question.createMany({
data: createdQuestions,
});
for (const q of updatedQuestions) {
await prisma.question.update({
where: { id: q.id },
data: q,
});
}
await prisma.question.deleteMany({
where: {
id: {
in: deletedIds,
},
},
});
console.log(
`Done, ${deletedIds.length} deleted, ${updatedQuestions.length} updated, ${createdQuestions.length} created`
);
}; };
export interface PlatformConfig { export interface PlatformConfig {

View File

@ -15,11 +15,15 @@ export const xrisk: Platform = {
encoding: "utf-8", encoding: "utf-8",
}); });
let results = JSON.parse(fileRaw); let results = JSON.parse(fileRaw);
results = results.map((item) => ({ results = results.map((item) => {
item.extra = item.moreoriginsdata;
delete item.moreoriginsdata;
return {
...item, ...item,
id: `${platformName}-${hash(item.title + " | " + item.url)}`, // some titles are non-unique, but title+url pair is always unique id: `${platformName}-${hash(item.title + " | " + item.url)}`, // some titles are non-unique, but title+url pair is always unique
platform: platformName, platform: platformName,
})); };
});
return results; return results;
}, },
}; };

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,5 @@
import { History, Question } from "@prisma/client";
import { prisma } from "../../backend/database/prisma"; import { prisma } from "../../backend/database/prisma";
import { platforms, QualityIndicators } from "../../backend/platforms"; import { platforms, QualityIndicators } from "../../backend/platforms";
import { builder } from "../builder"; import { builder } from "../builder";
@ -78,30 +80,29 @@ export const ProbabilityOptionObj = builder
}), }),
}); });
export const QuestionObj = builder.prismaObject("Question", { const QuestionShapeInterface = builder
findUnique: (question) => ({ id: question.id }), .interfaceRef<Question | History>("QuestionShape")
.implement({
fields: (t) => ({ fields: (t) => ({
id: t.exposeID("id", {
description: "Unique string which identifies the question",
}),
title: t.exposeString("title"), title: t.exposeString("title"),
description: t.exposeString("description"), description: t.exposeString("description"),
url: t.exposeString("url", { url: t.exposeString("url", {
description: description:
"Non-unique, a very small number of platforms have a page for more than one prediction", "Non-unique, a very small number of platforms have a page for more than one prediction",
}), }),
platform: t.field({
type: PlatformObj,
resolve: (parent) => parent.platform,
}),
timestamp: t.field({ timestamp: t.field({
type: "Date", type: "Date",
description: "Timestamp at which metaforecast fetched the question", description: "Timestamp at which metaforecast fetched the question",
resolve: (parent) => parent.timestamp, resolve: (parent) => parent.timestamp,
}), }),
platform: t.field({
type: PlatformObj,
resolve: (parent) => parent.platform,
}),
qualityIndicators: t.field({ qualityIndicators: t.field({
type: QualityIndicatorsObj, type: QualityIndicatorsObj,
resolve: (parent) => parent.qualityindicators as any as QualityIndicators, resolve: (parent) =>
parent.qualityindicators as any as QualityIndicators,
}), }),
options: t.field({ options: t.field({
type: [ProbabilityOptionObj], type: [ProbabilityOptionObj],
@ -112,10 +113,34 @@ export const QuestionObj = builder.prismaObject("Question", {
return options as any[]; return options as any[];
}, },
}), }),
}),
});
export const HistoryObj = builder.prismaObject("History", {
findUnique: (history) => ({ pk: history.pk }),
interfaces: [QuestionShapeInterface],
fields: (t) => ({
id: t.exposeID("pk", {
description: "History items are identified by their integer ids",
}),
questionId: t.exposeID("id", {
description: "Unique string which identifies the question",
}),
}),
});
export const QuestionObj = builder.prismaObject("Question", {
findUnique: (question) => ({ id: question.id }),
interfaces: [QuestionShapeInterface],
fields: (t) => ({
id: t.exposeID("id", {
description: "Unique string which identifies the question",
}),
visualization: t.string({ visualization: t.string({
resolve: (parent) => (parent.extra as any)?.visualization, // used for guesstimate only, see searchGuesstimate.ts resolve: (parent) => (parent.extra as any)?.visualization, // used for guesstimate only, see searchGuesstimate.ts
nullable: true, nullable: true,
}), }),
history: t.relation("history", {}),
}), }),
}); });
@ -125,10 +150,26 @@ builder.queryField("questions", (t) =>
type: "Question", type: "Question",
cursor: "id", cursor: "id",
maxSize: 1000, maxSize: 1000,
resolve: (query, parent, args, context, info) => resolve: (query) => prisma.question.findMany({ ...query }),
prisma.question.findMany({ ...query }),
}, },
{}, {},
{} {}
) )
); );
builder.queryField("question", (t) =>
t.field({
type: QuestionObj,
description: "Look up a single question by its id",
args: {
id: t.arg({ type: "ID", required: true }),
},
resolve: async (parent, args) => {
return await prisma.question.findUnique({
where: {
id: String(args.id),
},
});
},
})
);

View File

@ -99,6 +99,8 @@ export type Query = {
dashboard: Dashboard; dashboard: Dashboard;
/** Get a list of questions that are currently on the frontpage */ /** Get a list of questions that are currently on the frontpage */
frontpage: Array<Question>; frontpage: Array<Question>;
/** Look up a single question by its id */
question: Question;
questions: QueryQuestionsConnection; questions: QueryQuestionsConnection;
/** Search for questions; uses Algolia instead of the primary metaforecast database */ /** Search for questions; uses Algolia instead of the primary metaforecast database */
searchQuestions: Array<Question>; searchQuestions: Array<Question>;
@ -110,6 +112,11 @@ export type QueryDashboardArgs = {
}; };
export type QueryQuestionArgs = {
id: Scalars['ID'];
};
export type QueryQuestionsArgs = { export type QueryQuestionsArgs = {
after?: InputMaybe<Scalars['String']>; after?: InputMaybe<Scalars['String']>;
before?: InputMaybe<Scalars['String']>; before?: InputMaybe<Scalars['String']>;

View File

@ -1,7 +1,9 @@
import { NextPage } from "next";
import React from "react"; import React from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import gfm from "remark-gfm"; import gfm from "remark-gfm";
import { Card } from "../web/display/Card";
import { Layout } from "../web/display/Layout"; import { Layout } from "../web/display/Layout";
const readmeMarkdownText = `# About const readmeMarkdownText = `# About
@ -26,16 +28,16 @@ Also note that, whatever other redeeming features they might have, prediction ma
`; `;
export default function About() { const AboutPage: NextPage = () => {
return ( return (
<Layout page="about"> <Layout page="about">
<div className="px-2 py-2 bg-white rounded-md shadow place-content-stretch flex-grow place-self-center"> <Card highlightOnHover={false}>
<ReactMarkdown <div className="p-4">
remarkPlugins={[gfm]} <ReactMarkdown remarkPlugins={[gfm]} children={readmeMarkdownText} />
children={readmeMarkdownText}
className="m-5"
/>
</div> </div>
</Card>
</Layout> </Layout>
); );
} };
export default AboutPage;

View File

@ -0,0 +1,4 @@
export {
default,
getServerSideProps,
} from "../../web/questions/pages/QuestionPage";

View File

@ -1,3 +1,4 @@
import { NextPage } from "next";
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";
@ -45,7 +46,7 @@ const ToolCard: React.FC<Tool> = (tool) => {
} }
}; };
export default function Tools({ lastUpdated }) { const ToolsPage: NextPage = () => {
let tools: Tool[] = [ let tools: Tool[] = [
{ {
title: "Search", title: "Search",
@ -87,9 +88,11 @@ export default function Tools({ lastUpdated }) {
<Layout page="tools"> <Layout page="tools">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-4 mb-8 place-content-stretch"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-4 mb-8 place-content-stretch">
{tools.map((tool, i) => ( {tools.map((tool, i) => (
<ToolCard {...tool} key={`tool-${i}`} /> <ToolCard {...tool} key={i} />
))} ))}
</div> </div>
</Layout> </Layout>
); );
} };
export default ToolsPage;

View File

@ -0,0 +1,19 @@
import { FaRegClipboard } from "react-icons/fa";
interface Props {
text: string;
displayText: string;
}
export const CopyText: React.FC<Props> = ({ text, displayText }) => (
<div
className="flex items-center justify-center p-4 space-x-3 border rounded border-blue-400 hover:border-transparent bg-transparent hover:bg-blue-300 text-sm font-medium text-blue-400 hover:text-white cursor-pointer"
onClick={(e) => {
e.preventDefault();
navigator.clipboard.writeText(text);
}}
>
<span>{displayText}</span>
<FaRegClipboard />
</div>
);

49
src/web/common/Query.tsx Normal file
View File

@ -0,0 +1,49 @@
import React from "react";
import { TypedDocumentNode, useQuery } from "urql";
import { Spinner } from "./Spinner";
type Props<Variables extends object, Data> = {
document: TypedDocumentNode<Data, Variables>;
variables?: Variables;
children: ({ data }: { data: Data }) => React.ReactElement | null;
};
export function Query<Variables extends Object, Data>({
document,
variables,
children,
}: Props<Variables, Data>): React.ReactElement | null {
const [result] = useQuery({
query: document,
variables,
});
const { data, fetching, error } = result;
if (fetching) {
return (
<p>
<Spinner />
</p>
);
}
if (error) {
return (
<p>
<b>Internal error:</b> {error.message}
</p>
);
}
if (!data) {
return (
<p>
<b>Internal error</b>
</p>
);
}
return children({ data });
}

View File

@ -0,0 +1,22 @@
// via https://github.com/tailwindlabs/heroicons/issues/131#issuecomment-829192663
export const Spinner: React.FC = () => (
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-gray-500"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
);

View File

@ -2,12 +2,20 @@ const CardTitle: React.FC = ({ children }) => (
<div className="text-gray-800 text-lg font-medium">{children}</div> <div className="text-gray-800 text-lg font-medium">{children}</div>
); );
type CardType = React.FC & { interface Props {
highlightOnHover?: boolean;
}
type CardType = React.FC<Props> & {
Title: typeof CardTitle; Title: typeof CardTitle;
}; };
export const Card: CardType = ({ children }) => ( export const Card: CardType = ({ children, highlightOnHover = true }) => (
<div className="h-full px-4 py-3 bg-white hover:bg-gray-100 rounded-md shadow"> <div
className={`h-full px-4 py-3 bg-white rounded-md shadow ${
highlightOnHover ? "hover:bg-gray-100" : ""
}`}
>
{children} {children}
</div> </div>
); );

View File

@ -3,57 +3,28 @@ import { QuestionFragment } from "../../search/queries.generated";
type QualityIndicator = QuestionFragment["qualityIndicators"]; type QualityIndicator = QuestionFragment["qualityIndicators"];
type IndicatorName = keyof QualityIndicator; type IndicatorName = keyof QualityIndicator;
const formatQualityIndicator = (indicator: IndicatorName) => { // this duplication can probably be simplified with typescript magic, but this is good enough for now
let result: string | null = null; type UsedIndicatorName =
switch (indicator) { | "volume"
case "numForecasts": | "numForecasters"
result = null; | "spread"
break; | "sharesVolume"
| "liquidity"
| "tradeVolume"
| "openInterest";
case "stars": const qualityIndicatorLabels: { [k in UsedIndicatorName]: string } = {
result = null; // numForecasts: null,
break; // stars: null,
// yesBid: "Yes bid",
case "volume": // yesAsk: "Yes ask",
result = "Volume"; volume: "Volume",
break; numForecasters: "Forecasters",
spread: "Spread",
case "numForecasters": sharesVolume: "Shares vol.",
result = "Forecasters"; liquidity: "Liquidity",
break; tradeVolume: "Volume",
openInterest: "Interest",
// case "yesBid":
// result = null; // "Yes bid"
// break;
// case "yesAsk":
// result = null; // "Yes ask"
// break;
case "spread":
result = "Spread";
break;
case "sharesVolume":
result = "Shares vol.";
break;
case "openInterest":
result = "Interest";
break;
// case "resolution_data":
// result = null;
// break;
case "liquidity":
result = "Liquidity";
break;
case "tradeVolume":
result = "Volume";
break;
}
return result;
}; };
const formatNumber = (num) => { const formatNumber = (num) => {
@ -66,27 +37,16 @@ const formatNumber = (num) => {
} }
}; };
const formatQualityIndicators = (qualityIndicators: QualityIndicator) => {
let newQualityIndicators: { [k: string]: string | number } = {};
for (const key of Object.keys(qualityIndicators)) {
const newKey = formatQualityIndicator(key as IndicatorName);
if (newKey && qualityIndicators[key] !== null) {
newQualityIndicators[newKey] = qualityIndicators[key];
}
}
return newQualityIndicators;
};
/* Display functions*/ /* Display functions*/
const getPercentageSymbolIfNeeded = ({ const getPercentageSymbolIfNeeded = ({
indicator, indicator,
platform, platform,
}: { }: {
indicator: string; indicator: UsedIndicatorName;
platform: string; platform: string;
}) => { }) => {
let indicatorsWhichNeedPercentageSymbol = ["Spread"]; let indicatorsWhichNeedPercentageSymbol: IndicatorName[] = ["spread"];
if (indicatorsWhichNeedPercentageSymbol.includes(indicator)) { if (indicatorsWhichNeedPercentageSymbol.includes(indicator)) {
return "%"; return "%";
} else { } else {
@ -98,10 +58,15 @@ const getCurrencySymbolIfNeeded = ({
indicator, indicator,
platform, platform,
}: { }: {
indicator: string; indicator: UsedIndicatorName;
platform: string; platform: string;
}) => { }) => {
let indicatorsWhichNeedCurrencySymbol = ["Volume", "Interest", "Liquidity"]; const indicatorsWhichNeedCurrencySymbol: IndicatorName[] = [
"volume",
"tradeVolume",
"openInterest",
"liquidity",
];
let dollarPlatforms = ["predictit", "kalshi", "polymarket"]; let dollarPlatforms = ["predictit", "kalshi", "polymarket"];
if (indicatorsWhichNeedCurrencySymbol.includes(indicator)) { if (indicatorsWhichNeedCurrencySymbol.includes(indicator)) {
if (dollarPlatforms.includes(platform)) { if (dollarPlatforms.includes(platform)) {
@ -114,76 +79,50 @@ const getCurrencySymbolIfNeeded = ({
} }
}; };
const showFirstQualityIndicator = ({ const FirstQualityIndicator: React.FC<{
numforecasts, question: QuestionFragment;
lastUpdated, }> = ({ question }) => {
showTimeStamp, if (question.qualityIndicators.numForecasts) {
qualityindicators,
}) => {
if (!!numforecasts) {
return ( return (
<div className="flex col-span-1 row-span-1"> <div className="flex">
{/*<span>{` ${numforecasts == 1 ? "Forecast" : "Forecasts:"}`}</span>&nbsp;*/} <span>Forecasts:</span>&nbsp;
<span>{"Forecasts:"}</span>&nbsp; <span className="font-bold">
<span className="font-bold">{Number(numforecasts).toFixed(0)}</span> {Number(question.qualityIndicators.numForecasts).toFixed(0)}
</div>
);
} else if (showTimeStamp) {
return (
<span className="hidden sm:flex items-center justify-center text-gray-600 mt-2">
<svg className="ml-4 mr-1 mt-1" height="10" width="16">
<circle cx="4" cy="4" r="4" fill="rgb(29, 78, 216)" />
</svg>
{`Last updated: ${
lastUpdated ? lastUpdated.toISOString().slice(0, 10) : "unknown"
}`}
</span> </span>
</div>
); );
} else { } else {
return null; return null;
} }
}; };
const displayQualityIndicators: React.FC<{ const QualityIndicatorsList: React.FC<{
numforecasts: number; question: QuestionFragment;
lastUpdated: Date; }> = ({ question }) => {
showTimeStamp: boolean;
qualityindicators: QuestionFragment["qualityIndicators"];
platform: string; // id string - e.g. "goodjudgment", not "Good Judgment"
}> = ({
numforecasts,
lastUpdated,
showTimeStamp,
qualityindicators,
platform,
}) => {
// grid grid-cols-1
return ( return (
<div className="text-sm"> <div className="text-sm">
{showFirstQualityIndicator({ <FirstQualityIndicator question={question} />
numforecasts, {Object.entries(question.qualityIndicators).map((entry, i) => {
lastUpdated, const indicatorLabel = qualityIndicatorLabels[entry[0]];
showTimeStamp, if (!indicatorLabel || entry[1] === null) return;
qualityindicators, const indicator = entry[0] as UsedIndicatorName; // guaranteed by the previous line
})} const value = entry[1];
{Object.entries(formatQualityIndicators(qualityindicators)).map(
(entry, i) => {
return ( return (
<div className="col-span-1 row-span-1"> <div key={indicator}>
<span>{`${entry[0]}:`}</span>&nbsp; <span>{indicatorLabel}:</span>&nbsp;
<span className="font-bold"> <span className="font-bold">
{`${getCurrencySymbolIfNeeded({ {`${getCurrencySymbolIfNeeded({
indicator: entry[0], indicator,
platform, platform: question.platform.id,
})}${formatNumber(entry[1])}${getPercentageSymbolIfNeeded({ })}${formatNumber(value)}${getPercentageSymbolIfNeeded({
indicator: entry[0], indicator,
platform, platform: question.platform.id,
})}`} })}`}
</span> </span>
</div> </div>
); );
} })}
)}
</div> </div>
); );
}; };
@ -244,30 +183,14 @@ function getStarsColor(numstars: number) {
} }
interface Props { interface Props {
stars: any; question: QuestionFragment;
platform: string;
platformLabel: string;
numforecasts: any;
qualityindicators: QuestionFragment["qualityIndicators"];
lastUpdated: Date;
showTimeStamp: boolean;
expandFooterToFullWidth: boolean; expandFooterToFullWidth: boolean;
} }
export const QuestionFooter: React.FC<Props> = ({ export const QuestionFooter: React.FC<Props> = ({
stars, question,
platform,
platformLabel,
numforecasts,
qualityindicators,
lastUpdated,
showTimeStamp,
expandFooterToFullWidth, expandFooterToFullWidth,
}) => { }) => {
// I experimented with justify-evenly, justify-around, etc., here: https://tailwindcss.com/docs/justify-content
// I came to the conclusion that as long as the description isn't justified too, aligning the footer symmetrically doesn't make sense
// because the contrast is jarring.
let debuggingWithBackground = false;
return ( return (
<div <div
className={`grid grid-cols-3 ${ className={`grid grid-cols-3 ${
@ -275,18 +198,18 @@ export const QuestionFooter: React.FC<Props> = ({
} text-gray-500 mb-2 mt-1`} } text-gray-500 mb-2 mt-1`}
> >
<div <div
className={`self-center col-span-1 ${getStarsColor(stars)} ${ className={`self-center col-span-1 ${getStarsColor(
debuggingWithBackground ? "bg-red-200" : "" question.qualityIndicators.stars
}`} )}`}
> >
{getstars(stars)} {getstars(question.qualityIndicators.stars)}
</div> </div>
<div <div
className={`${ className={`${
expandFooterToFullWidth ? "place-self-center" : "self-center" expandFooterToFullWidth ? "place-self-center" : "self-center"
} col-span-1 font-bold ${debuggingWithBackground ? "bg-red-100" : ""}`} } col-span-1 font-bold`}
> >
{platformLabel {question.platform.label
.replace("Good Judgment Open", "GJOpen") .replace("Good Judgment Open", "GJOpen")
.replace(/ /g, "\u00a0")} .replace(/ /g, "\u00a0")}
</div> </div>
@ -295,15 +218,9 @@ export const QuestionFooter: React.FC<Props> = ({
expandFooterToFullWidth expandFooterToFullWidth
? "justify-self-end mr-4" ? "justify-self-end mr-4"
: "justify-self-center" : "justify-self-center"
} col-span-1 ${debuggingWithBackground ? "bg-red-100" : ""}`} } col-span-1`}
> >
{displayQualityIndicators({ <QualityIndicatorsList question={question} />
numforecasts,
lastUpdated,
showTimeStamp,
qualityindicators,
platform,
})}
</div> </div>
</div> </div>
); );

View File

@ -1,6 +1,9 @@
import { FaRegClipboard } from "react-icons/fa"; import Link from "next/link";
import { FaExpand } from "react-icons/fa";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { CopyText } from "../../common/CopyText";
import { QuestionOptions } from "../../questions/components/QuestionOptions";
import { QuestionFragment } from "../../search/queries.generated"; import { QuestionFragment } from "../../search/queries.generated";
import { Card } from "../Card"; import { Card } from "../Card";
import { QuestionFooter } from "./QuestionFooter"; import { QuestionFooter } from "./QuestionFooter";
@ -12,7 +15,7 @@ const truncateText = (length: number, text: string): string => {
if (!!text && text.length <= length) { if (!!text && text.length <= length) {
return text; return text;
} }
let breakpoints = " .!?"; const breakpoints = " .!?";
let lastLetter = null; let lastLetter = null;
let lastIndex = null; let lastIndex = null;
for (let index = length; index > 0; index--) { for (let index = length; index > 0; index--) {
@ -29,17 +32,6 @@ const truncateText = (length: number, text: string): string => {
return truncatedText; return truncatedText;
}; };
const formatProbability = (probability: number) => {
let percentage = probability * 100;
let percentageCapped =
percentage < 1
? "< 1%"
: percentage > 99
? "> 99%"
: percentage.toFixed(0) + "%";
return percentageCapped;
};
// replaceAll polyfill // replaceAll polyfill
function escapeRegExp(string) { function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
@ -94,99 +86,12 @@ const cleanText = (text: string): string => {
return textString; return textString;
}; };
const primaryForecastColor = (probability: number) => {
if (probability < 0.03) {
return "bg-red-600";
} else if (probability < 0.1) {
return "bg-red-600 opacity-80";
} else if (probability < 0.2) {
return "bg-red-600 opacity-70";
} else if (probability < 0.3) {
return "bg-red-600 opacity-60";
} else if (probability < 0.4) {
return "bg-red-600 opacity-50";
} else if (probability < 0.5) {
return "bg-gray-500";
} else if (probability < 0.6) {
return "bg-gray-500";
} else if (probability < 0.7) {
return "bg-green-600 opacity-50";
} else if (probability < 0.8) {
return "bg-green-600 opacity-60";
} else if (probability < 0.9) {
return "bg-green-600 opacity-70";
} else if (probability < 0.97) {
return "bg-green-600 opacity-80";
} else {
return "bg-green-600";
}
};
const textColor = (probability: number) => {
if (probability < 0.03) {
return "text-red-600";
} else if (probability < 0.1) {
return "text-red-600 opacity-80";
} else if (probability < 0.2) {
return "text-red-600 opacity-80";
} else if (probability < 0.3) {
return "text-red-600 opacity-70";
} else if (probability < 0.4) {
return "text-red-600 opacity-70";
} else if (probability < 0.5) {
return "text-gray-500";
} else if (probability < 0.6) {
return "text-gray-500";
} else if (probability < 0.7) {
return "text-green-600 opacity-70";
} else if (probability < 0.8) {
return "text-green-600 opacity-70";
} else if (probability < 0.9) {
return "text-green-600 opacity-80";
} else if (probability < 0.97) {
return "text-green-600 opacity-80";
} else {
return "text-green-600";
}
};
const primaryEstimateAsText = (probability: number) => {
if (probability < 0.03) {
return "Exceptionally unlikely";
} else if (probability < 0.1) {
return "Very unlikely";
} else if (probability < 0.4) {
return "Unlikely";
} else if (probability < 0.6) {
return "About Even";
} else if (probability < 0.9) {
return "Likely";
} else if (probability < 0.97) {
return "Very likely";
} else {
return "Virtually certain";
}
};
// Logical checks
const checkIfDisplayTimeStampAtBottom = (qualityIndicators: {
[k: string]: any;
}) => {
let indicators = Object.keys(qualityIndicators);
if (indicators.length == 1 && indicators[0] == "stars") {
return true;
} else {
return false;
}
};
// Auxiliary components // Auxiliary components
const DisplayMarkdown: React.FC<{ description: string }> = ({ const DisplayMarkdown: React.FC<{ description: string }> = ({
description, description,
}) => { }) => {
let formatted = truncateText(250, cleanText(description)); const formatted = truncateText(250, cleanText(description));
// overflow-hidden overflow-ellipsis h-24 // overflow-hidden overflow-ellipsis h-24
return formatted === "" ? null : ( return formatted === "" ? null : (
<div className="overflow-clip"> <div className="overflow-clip">
@ -197,63 +102,6 @@ const DisplayMarkdown: React.FC<{ description: string }> = ({
); );
}; };
const OptionRow: React.FC<{ option: any }> = ({ option }) => {
const chooseColor = (probability: number) => {
if (probability < 0.1) {
return "bg-blue-50 text-blue-500";
} else if (probability < 0.3) {
return "bg-blue-100 text-blue-600";
} else if (probability < 0.7) {
return "bg-blue-200 text-blue-700";
} else {
return "bg-blue-300 text-blue-800";
}
};
return (
<div className="flex items-center">
<div
className={`${chooseColor(
option.probability
)} w-14 flex-none rounded-md py-0.5 text-sm text-center`}
>
{formatProbability(option.probability)}
</div>
<div className="text-gray-700 pl-3 leading-snug text-sm">
{option.name}
</div>
</div>
);
};
const ForecastOptions: React.FC<{ options: any[] }> = ({ options }) => {
const optionsSorted = options.sort((a, b) => b.probability - a.probability);
const optionsMax5 = !!optionsSorted.slice ? optionsSorted.slice(0, 5) : []; // display max 5 options.
return (
<div className="space-y-2">
{optionsMax5.map((option, i) => (
<OptionRow option={option} key={i} />
))}
</div>
);
};
const CopyText: React.FC<{ text: string; displayText: string }> = ({
text,
displayText,
}) => (
<div
className="flex items-center justify-center p-4 space-x-3 border rounded border-blue-400 hover:border-transparent bg-transparent hover:bg-blue-300 text-sm font-medium text-blue-400 hover:text-white"
onClick={(e) => {
e.preventDefault();
navigator.clipboard.writeText(text);
}}
>
<span>{displayText}</span>
<FaRegClipboard />
</div>
);
const LastUpdated: React.FC<{ timestamp: Date }> = ({ timestamp }) => ( const LastUpdated: React.FC<{ timestamp: Date }> = ({ timestamp }) => (
<div className="flex items-center"> <div className="flex items-center">
<svg className="mt-1" height="10" width="16"> <svg className="mt-1" height="10" width="16">
@ -276,116 +124,91 @@ interface Props {
} }
export const DisplayQuestion: React.FC<Props> = ({ export const DisplayQuestion: React.FC<Props> = ({
question: { question,
id,
title,
url,
platform,
description,
options,
qualityIndicators,
timestamp,
visualization,
},
showTimeStamp, showTimeStamp,
expandFooterToFullWidth, expandFooterToFullWidth,
showIdToggle, showIdToggle,
}) => { }) => {
const lastUpdated = new Date(timestamp * 1000); const { options } = question;
const displayTimestampAtBottom = const lastUpdated = new Date(question.timestamp * 1000);
checkIfDisplayTimeStampAtBottom(qualityIndicators);
const yesNoOptions = const isBinary =
options.length === 2 && options.length === 2 &&
(options[0].name === "Yes" || options[0].name === "No"); (options[0].name === "Yes" || options[0].name === "No");
return ( return (
<a className="textinherit no-underline" href={url} target="_blank">
<Card> <Card>
<div className="h-full flex flex-col space-y-4"> <div className="h-full flex flex-col space-y-4">
<div className="flex-grow space-y-4"> <div className="flex-grow space-y-4">
{showIdToggle ? ( {showIdToggle ? (
<div className="mx-10"> <div className="mx-10">
<CopyText text={id} displayText={`[${id}]`} /> <CopyText text={question.id} displayText={`[${question.id}]`} />
</div> </div>
) : null} ) : null}
<Card.Title>{title}</Card.Title> <div>
{yesNoOptions && ( {process.env.NEXT_PUBLIC_ENABLE_QUESTION_PAGES ? (
<Link href={`/questions/${question.id}`} passHref>
<a className="float-right block ml-2 mt-1.5">
<FaExpand
size="18"
className="text-gray-400 hover:text-gray-700"
/>
</a>
</Link>
) : null}
<Card.Title>
<a
className="text-black no-underline"
href={question.url}
target="_blank"
>
{question.title}
</a>
</Card.Title>
</div>
{isBinary ? (
<div className="flex justify-between"> <div className="flex justify-between">
<div className="space-x-2"> <QuestionOptions options={options} />
<span <div className={`hidden ${showTimeStamp ? "sm:block" : ""}`}>
className={`${primaryForecastColor(
options[0].probability
)} text-white w-16 rounded-md px-1.5 py-0.5 font-bold`}
>
{formatProbability(options[0].probability)}
</span>
<span
className={`${textColor(
options[0].probability
)} text-gray-500 inline-block`}
>
{primaryEstimateAsText(options[0].probability)}
</span>
</div>
<div
className={`hidden ${
showTimeStamp && !displayTimestampAtBottom ? "sm:block" : ""
}`}
>
<LastUpdated timestamp={lastUpdated} /> <LastUpdated timestamp={lastUpdated} />
</div> </div>
</div> </div>
)} ) : (
{!yesNoOptions && (
<div className="space-y-2"> <div className="space-y-2">
<ForecastOptions options={options} /> <QuestionOptions options={options} />
<div <div className={`hidden ${showTimeStamp ? "sm:block" : ""} ml-6`}>
className={`hidden ${
showTimeStamp && !displayTimestampAtBottom ? "sm:block" : ""
} ml-6`}
>
<LastUpdated timestamp={lastUpdated} /> <LastUpdated timestamp={lastUpdated} />
</div> </div>
</div> </div>
)} )}
{platform.id !== "guesstimate" && options.length < 3 && ( {question.platform.id !== "guesstimate" && options.length < 3 && (
<div className="text-gray-500"> <div className="text-gray-500">
<DisplayMarkdown description={description} /> <DisplayMarkdown description={question.description} />
</div> </div>
)} )}
{platform.id === "guesstimate" && ( {question.platform.id === "guesstimate" && (
<img <img
className="rounded-sm" className="rounded-sm"
src={visualization} src={question.visualization}
alt="Guesstimate Screenshot" alt="Guesstimate Screenshot"
/> />
)} )}
</div> </div>
<div <div
className={`sm:hidden ${ className={`sm:hidden ${!showTimeStamp ? "hidden" : ""} self-center`}
!showTimeStamp ? "hidden" : ""
} self-center`}
> >
{/* This one is exclusively for mobile*/} {/* This one is exclusively for mobile*/}
<LastUpdated timestamp={lastUpdated} /> <LastUpdated timestamp={lastUpdated} />
</div> </div>
<div className="w-full"> <div className="w-full">
<QuestionFooter <QuestionFooter
stars={qualityIndicators.stars} question={question}
platform={platform.id}
platformLabel={platform.label}
numforecasts={qualityIndicators.numForecasts}
qualityindicators={qualityIndicators}
lastUpdated={lastUpdated}
showTimeStamp={showTimeStamp && displayTimestampAtBottom}
expandFooterToFullWidth={expandFooterToFullWidth} expandFooterToFullWidth={expandFooterToFullWidth}
/> />
</div> </div>
</div> </div>
</Card> </Card>
</a>
); );
}; };

View File

@ -4,15 +4,32 @@ import React, { ErrorInfo } from "react";
import { Logo2 } from "../icons/index"; import { Logo2 } from "../icons/index";
/* Utilities */ interface MenuItem {
const classNameSelected = (isSelected: boolean) => page: string;
`no-underline py-4 px-2 ml-4 text-md font-medium cursor-pointer border-b-2 border-transparent ${ link: string;
isSelected title: string;
? "text-blue-700 border-blue-700" }
: "text-gray-400 hover:text-blue-500 hover:border-blue-500"
}`;
let calculateLastUpdate = () => { const menu: MenuItem[] = [
{
page: "search",
link: "/",
title: "Search",
},
{
page: "tools",
link: "/tools",
title: "Tools",
},
{
page: "about",
link: "/about",
title: "About",
},
];
/* Utilities */
const calculateLastUpdate = () => {
let today = new Date().toISOString(); let today = new Date().toISOString();
let yesterdayObj = new Date(); let yesterdayObj = new Date();
yesterdayObj.setDate(yesterdayObj.getDate() - 1); yesterdayObj.setDate(yesterdayObj.getDate() - 1);
@ -66,26 +83,16 @@ class ErrorBoundary extends React.Component<
} }
} }
interface Props {
page: string; // id used for menu
}
/* Main */ /* Main */
export const Layout = ({ page, children }) => { export const Layout: React.FC<Props> = ({ page, children }) => {
let lastUpdated = calculateLastUpdate(); let lastUpdated = calculateLastUpdate();
// The correct way to do this would be by passing a prop to Layout, // The correct way to do this would be by passing a prop to Layout,
// and to get the last updating using server side props. // and to get the last updating using server side props.
const refreshPage = () => {
// window.location.reload(true);
// window.location.replace(window.location.pathname);
// window.location.reload();
// https://developer.mozilla.org/en-US/docs/Web/API/Location/reload
// https://developer.mozilla.org/en-US/docs/Web/API/Location/replace
// https://developer.mozilla.org/en-US/docs/Web/API/Location/assign
// window.location.hostname
if (typeof window !== "undefined") {
if ((window.location as any) != window.location.pathname) {
window.location.assign(window.location.pathname);
}
}
};
return ( return (
<div> <div>
<Head> <Head>
@ -95,23 +102,20 @@ export const Layout = ({ page, children }) => {
<div> <div>
<nav className="bg-white shadow"> <nav className="bg-white shadow">
<div className="container max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="container max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="items-center justify-between flex"> <div className="flex items-center justify-between">
<div className="flex sm:flex-row"> <div className="flex">
<button onClick={refreshPage}> <Link href="/" passHref>
<a className="no-underline font-md justify-center items-center flex"> <a className="no-underline font-md justify-center items-center flex">
<span className="mr-2 sm:text-lg text-blue-800"> <span className="mr-2">
<Logo2 className="mt-1 mr-1 h-8 w-8" /> <Logo2 className="mt-1 mr-1 h-8 w-8" />
</span> </span>
<span className="text-sm sm:text-2xl text-gray-700"> <span className="text-sm sm:text-2xl text-gray-700">
Metaforecast Metaforecast
</span> </span>
</a> </a>
</button> </Link>
<div {lastUpdated ? (
className={`flex py-4 px-2 sm:ml-4 text-base text-gray-400 ${ <div className="flex py-4 px-2 sm:ml-4">
lastUpdated || "hidden"
}`}
>
<div className="hidden sm:inline-flex items-center text-gray-700"> <div className="hidden sm:inline-flex items-center text-gray-700">
<svg className="ml-4 mr-1 mt-1" height="10" width="16"> <svg className="ml-4 mr-1 mt-1" height="10" width="16">
<circle cx="4" cy="4" r="4" fill="rgb(29, 78, 216)" /> <circle cx="4" cy="4" r="4" fill="rgb(29, 78, 216)" />
@ -124,25 +128,30 @@ export const Layout = ({ page, children }) => {
}`}</span> }`}</span>
</div> </div>
</div> </div>
) : null}
</div> </div>
<div className="flex flex-row-reverse items-start space-x-4 text-sm sm:text-lg md:text-lg lg:text-lg"> <div className="flex space-x-4">
<Link href={`/about`} passHref> {menu.map((item) => (
<a className={classNameSelected(page === "about")}>About</a> <Link href={item.link} passHref key={item.page}>
</Link> <a
<Link href={`/tools`} passHref> className={`no-underline py-4 px-2 text-sm sm:text-lg font-medium cursor-pointer border-b-2 border-transparent ${
<a className={classNameSelected(page === "tools")}>Tools</a> page === item.page
</Link> ? "text-blue-700 border-blue-700"
<Link href={`/`} passHref> : "text-gray-400 hover:text-blue-500 hover:border-blue-500"
<a className={classNameSelected(page === "search")}>Search</a> }`}
>
{item.title}
</a>
</Link> </Link>
))}
</div> </div>
</div> </div>
</div> </div>
</nav> </nav>
<main> <main>
<ErrorBoundary> <ErrorBoundary>
<div className="container max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-left pt-5"> <div className="container max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-5">
{children} {children}
</div> </div>
</ErrorBoundary> </ErrorBoundary>

View File

@ -0,0 +1,147 @@
import { QuestionFragment } from "../../search/queries.generated";
import { formatProbability } from "../utils";
type Option = QuestionFragment["options"][0];
const textColor = (probability: number) => {
if (probability < 0.03) {
return "text-red-600";
} else if (probability < 0.1) {
return "text-red-600 opacity-80";
} else if (probability < 0.2) {
return "text-red-600 opacity-80";
} else if (probability < 0.3) {
return "text-red-600 opacity-70";
} else if (probability < 0.4) {
return "text-red-600 opacity-70";
} else if (probability < 0.5) {
return "text-gray-500";
} else if (probability < 0.6) {
return "text-gray-500";
} else if (probability < 0.7) {
return "text-green-600 opacity-70";
} else if (probability < 0.8) {
return "text-green-600 opacity-70";
} else if (probability < 0.9) {
return "text-green-600 opacity-80";
} else if (probability < 0.97) {
return "text-green-600 opacity-80";
} else {
return "text-green-600";
}
};
const primaryForecastColor = (probability: number) => {
if (probability < 0.03) {
return "bg-red-600";
} else if (probability < 0.1) {
return "bg-red-600 opacity-80";
} else if (probability < 0.2) {
return "bg-red-600 opacity-70";
} else if (probability < 0.3) {
return "bg-red-600 opacity-60";
} else if (probability < 0.4) {
return "bg-red-600 opacity-50";
} else if (probability < 0.5) {
return "bg-gray-500";
} else if (probability < 0.6) {
return "bg-gray-500";
} else if (probability < 0.7) {
return "bg-green-600 opacity-50";
} else if (probability < 0.8) {
return "bg-green-600 opacity-60";
} else if (probability < 0.9) {
return "bg-green-600 opacity-70";
} else if (probability < 0.97) {
return "bg-green-600 opacity-80";
} else {
return "bg-green-600";
}
};
const primaryEstimateAsText = (probability: number) => {
if (probability < 0.03) {
return "Exceptionally unlikely";
} else if (probability < 0.1) {
return "Very unlikely";
} else if (probability < 0.4) {
return "Unlikely";
} else if (probability < 0.6) {
return "About Even";
} else if (probability < 0.9) {
return "Likely";
} else if (probability < 0.97) {
return "Very likely";
} else {
return "Virtually certain";
}
};
const chooseColor = (probability: number) => {
if (probability < 0.1) {
return "bg-blue-50 text-blue-500";
} else if (probability < 0.3) {
return "bg-blue-100 text-blue-600";
} else if (probability < 0.7) {
return "bg-blue-200 text-blue-700";
} else {
return "bg-blue-300 text-blue-800";
}
};
const OptionRow: React.FC<{ option: Option }> = ({ option }) => {
return (
<div className="flex items-center">
<div
className={`${chooseColor(
option.probability
)} w-14 flex-none rounded-md py-0.5 text-sm text-center`}
>
{formatProbability(option.probability)}
</div>
<div className="text-gray-700 pl-3 leading-snug text-sm">
{option.name}
</div>
</div>
);
};
export const QuestionOptions: React.FC<{ options: Option[] }> = ({
options,
}) => {
const isBinary =
options.length === 2 &&
(options[0].name === "Yes" || options[0].name === "No");
const optionsSorted = options.sort((a, b) => b.probability - a.probability);
const optionsMax5 = !!optionsSorted.slice ? optionsSorted.slice(0, 5) : []; // display max 5 options.
if (isBinary) {
return (
<div className="space-x-2">
<span
className={`${primaryForecastColor(
options[0].probability
)} text-white w-16 rounded-md px-1.5 py-0.5 font-bold`}
>
{formatProbability(options[0].probability)}
</span>
<span
className={`${textColor(
options[0].probability
)} text-gray-500 inline-block`}
>
{primaryEstimateAsText(options[0].probability)}
</span>
</div>
);
} else {
return (
<div className="space-y-2">
{optionsMax5.map((option, i) => (
<OptionRow option={option} key={i} />
))}
</div>
);
}
};

View File

@ -0,0 +1,75 @@
import { GetServerSideProps, NextPage } from "next";
import ReactMarkdown from "react-markdown";
import { Query } from "../../common/Query";
import { Card } from "../../display/Card";
import { QuestionFooter } from "../../display/DisplayQuestion/QuestionFooter";
import { Layout } from "../../display/Layout";
import { QuestionFragment } from "../../search/queries.generated";
import { ssrUrql } from "../../urql";
import { QuestionOptions } from "../components/QuestionOptions";
import { QuestionByIdDocument } from "../queries.generated";
interface Props {
id: string;
}
export const getServerSideProps: GetServerSideProps<Props> = async (
context
) => {
const [ssrCache, client] = ssrUrql();
const id = context.query.id as string;
const question =
(await client.query(QuestionByIdDocument, { id }).toPromise()).data
?.result || null;
if (!question) {
context.res.statusCode = 404;
}
return {
props: {
urqlState: ssrCache.extractData(),
id,
},
};
};
const QuestionCardContents: React.FC<{ question: QuestionFragment }> = ({
question,
}) => (
<div className="space-y-4">
<h1>
<a
className="text-black no-underline"
href={question.url}
target="_blank"
>
{question.title}
</a>
</h1>
<QuestionFooter question={question} expandFooterToFullWidth={true} />
<QuestionOptions options={question.options} />
<ReactMarkdown linkTarget="_blank" className="font-normal">
{question.description}
</ReactMarkdown>
</div>
);
const QuestionPage: NextPage<Props> = ({ id }) => {
return (
<Layout page="question">
<div className="max-w-2xl mx-auto">
<Card highlightOnHover={false}>
<Query document={QuestionByIdDocument} variables={{ id }}>
{({ data }) => <QuestionCardContents question={data.result} />}
</Query>
</Card>
</div>
</Layout>
);
};
export default QuestionPage;

View File

@ -0,0 +1,13 @@
import * as Types from '../../graphql/types.generated';
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
import { QuestionFragmentDoc } from '../search/queries.generated';
export type QuestionByIdQueryVariables = Types.Exact<{
id: Types.Scalars['ID'];
}>;
export type QuestionByIdQuery = { __typename?: 'Query', result: { __typename?: 'Question', id: string, url: string, title: string, description: string, timestamp: number, visualization?: string | null, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }>, platform: { __typename?: 'Platform', id: string, label: string }, qualityIndicators: { __typename?: 'QualityIndicators', stars: number, numForecasts?: number | null, numForecasters?: number | null, volume?: number | null, spread?: number | null, sharesVolume?: number | null, openInterest?: number | null, liquidity?: number | null, tradeVolume?: number | null } } };
export const QuestionByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"QuestionById"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"result"},"name":{"kind":"Name","value":"question"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Question"}}]}}]}},...QuestionFragmentDoc.definitions]} as unknown as DocumentNode<QuestionByIdQuery, QuestionByIdQueryVariables>;

View File

@ -0,0 +1,5 @@
query QuestionById($id: ID!) {
result: question(id: $id) {
...Question
}
}

View File

@ -0,0 +1,10 @@
export const formatProbability = (probability: number) => {
let percentage = probability * 100;
let percentageCapped =
percentage < 1
? "< 1%"
: percentage > 99
? "> 99%"
: percentage.toFixed(0) + "%";
return percentageCapped;
};