feat: question pages; various refactorings
This commit is contained in:
parent
ece3ac4780
commit
ab6f17ffe0
|
@ -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.
|
||||
|
||||
# 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
|
||||
|
||||
**I want to check out what Metaforecast's GraphQL API is capable of**
|
||||
|
|
|
@ -93,6 +93,9 @@ type Query {
|
|||
|
||||
"""Get a list of questions that are currently on the frontpage"""
|
||||
frontpage: [Question!]!
|
||||
|
||||
"""Look up a single question by its id"""
|
||||
question(id: ID!): Question!
|
||||
questions(after: String, before: String, first: Int, last: Int): QueryQuestionsConnection!
|
||||
|
||||
"""
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -132,3 +132,20 @@ builder.queryField("questions", (t) =>
|
|||
{}
|
||||
)
|
||||
);
|
||||
|
||||
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),
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
|
|
|
@ -99,6 +99,8 @@ export type Query = {
|
|||
dashboard: Dashboard;
|
||||
/** Get a list of questions that are currently on the frontpage */
|
||||
frontpage: Array<Question>;
|
||||
/** Look up a single question by its id */
|
||||
question: Question;
|
||||
questions: QueryQuestionsConnection;
|
||||
/** Search for questions; uses Algolia instead of the primary metaforecast database */
|
||||
searchQuestions: Array<Question>;
|
||||
|
@ -110,6 +112,11 @@ export type QueryDashboardArgs = {
|
|||
};
|
||||
|
||||
|
||||
export type QueryQuestionArgs = {
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryQuestionsArgs = {
|
||||
after?: InputMaybe<Scalars['String']>;
|
||||
before?: InputMaybe<Scalars['String']>;
|
||||
|
|
4
src/pages/questions/[id].tsx
Normal file
4
src/pages/questions/[id].tsx
Normal file
|
@ -0,0 +1,4 @@
|
|||
export {
|
||||
default,
|
||||
getServerSideProps,
|
||||
} from "../../web/questions/pages/QuestionPage";
|
19
src/web/common/CopyText.tsx
Normal file
19
src/web/common/CopyText.tsx
Normal 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"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText(text);
|
||||
}}
|
||||
>
|
||||
<span>{displayText}</span>
|
||||
<FaRegClipboard />
|
||||
</div>
|
||||
);
|
49
src/web/common/Query.tsx
Normal file
49
src/web/common/Query.tsx
Normal 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 });
|
||||
}
|
22
src/web/common/Spinner.tsx
Normal file
22
src/web/common/Spinner.tsx
Normal 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>
|
||||
);
|
|
@ -114,18 +114,19 @@ const getCurrencySymbolIfNeeded = ({
|
|||
}
|
||||
};
|
||||
|
||||
const showFirstQualityIndicator = ({
|
||||
numforecasts,
|
||||
lastUpdated,
|
||||
showTimeStamp,
|
||||
qualityindicators,
|
||||
}) => {
|
||||
if (!!numforecasts) {
|
||||
const showFirstQualityIndicator: React.FC<{
|
||||
question: QuestionFragment;
|
||||
showTimeStamp: boolean;
|
||||
}> = ({ question, showTimeStamp }) => {
|
||||
const lastUpdated = new Date(question.timestamp * 1000);
|
||||
if (!!question.qualityIndicators.numForecasts) {
|
||||
return (
|
||||
<div className="flex col-span-1 row-span-1">
|
||||
{/*<span>{` ${numforecasts == 1 ? "Forecast" : "Forecasts:"}`}</span> */}
|
||||
<span>{"Forecasts:"}</span>
|
||||
<span className="font-bold">{Number(numforecasts).toFixed(0)}</span>
|
||||
<span>Forecasts:</span>
|
||||
<span className="font-bold">
|
||||
{Number(question.qualityIndicators.numForecasts).toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
} else if (showTimeStamp) {
|
||||
|
@ -145,39 +146,28 @@ const showFirstQualityIndicator = ({
|
|||
};
|
||||
|
||||
const displayQualityIndicators: React.FC<{
|
||||
numforecasts: number;
|
||||
lastUpdated: Date;
|
||||
question: QuestionFragment;
|
||||
showTimeStamp: boolean;
|
||||
qualityindicators: QuestionFragment["qualityIndicators"];
|
||||
platform: string; // id string - e.g. "goodjudgment", not "Good Judgment"
|
||||
}> = ({
|
||||
numforecasts,
|
||||
lastUpdated,
|
||||
showTimeStamp,
|
||||
qualityindicators,
|
||||
platform,
|
||||
}) => {
|
||||
// grid grid-cols-1
|
||||
}> = ({ question, showTimeStamp }) => {
|
||||
const { qualityIndicators } = question;
|
||||
return (
|
||||
<div className="text-sm">
|
||||
{showFirstQualityIndicator({
|
||||
numforecasts,
|
||||
lastUpdated,
|
||||
question,
|
||||
showTimeStamp,
|
||||
qualityindicators,
|
||||
})}
|
||||
{Object.entries(formatQualityIndicators(qualityindicators)).map(
|
||||
{Object.entries(formatQualityIndicators(question.qualityIndicators)).map(
|
||||
(entry, i) => {
|
||||
return (
|
||||
<div className="col-span-1 row-span-1">
|
||||
<span>{`${entry[0]}:`}</span>
|
||||
<span>${entry[0]}:</span>
|
||||
<span className="font-bold">
|
||||
{`${getCurrencySymbolIfNeeded({
|
||||
indicator: entry[0],
|
||||
platform,
|
||||
platform: question.platform.id,
|
||||
})}${formatNumber(entry[1])}${getPercentageSymbolIfNeeded({
|
||||
indicator: entry[0],
|
||||
platform,
|
||||
platform: question.platform.id,
|
||||
})}`}
|
||||
</span>
|
||||
</div>
|
||||
|
@ -244,29 +234,16 @@ function getStarsColor(numstars: number) {
|
|||
}
|
||||
|
||||
interface Props {
|
||||
stars: any;
|
||||
platform: string;
|
||||
platformLabel: string;
|
||||
numforecasts: any;
|
||||
qualityindicators: QuestionFragment["qualityIndicators"];
|
||||
lastUpdated: Date;
|
||||
question: QuestionFragment;
|
||||
showTimeStamp: boolean;
|
||||
expandFooterToFullWidth: boolean;
|
||||
}
|
||||
|
||||
export const QuestionFooter: React.FC<Props> = ({
|
||||
stars,
|
||||
platform,
|
||||
platformLabel,
|
||||
numforecasts,
|
||||
qualityindicators,
|
||||
lastUpdated,
|
||||
question,
|
||||
showTimeStamp,
|
||||
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 (
|
||||
<div
|
||||
|
@ -275,18 +252,18 @@ export const QuestionFooter: React.FC<Props> = ({
|
|||
} text-gray-500 mb-2 mt-1`}
|
||||
>
|
||||
<div
|
||||
className={`self-center col-span-1 ${getStarsColor(stars)} ${
|
||||
debuggingWithBackground ? "bg-red-200" : ""
|
||||
}`}
|
||||
className={`self-center col-span-1 ${getStarsColor(
|
||||
question.qualityIndicators.stars
|
||||
)} ${debuggingWithBackground ? "bg-red-200" : ""}`}
|
||||
>
|
||||
{getstars(stars)}
|
||||
{getstars(question.qualityIndicators.stars)}
|
||||
</div>
|
||||
<div
|
||||
className={`${
|
||||
expandFooterToFullWidth ? "place-self-center" : "self-center"
|
||||
} col-span-1 font-bold ${debuggingWithBackground ? "bg-red-100" : ""}`}
|
||||
>
|
||||
{platformLabel
|
||||
{question.platform.label
|
||||
.replace("Good Judgment Open", "GJOpen")
|
||||
.replace(/ /g, "\u00a0")}
|
||||
</div>
|
||||
|
@ -298,11 +275,8 @@ export const QuestionFooter: React.FC<Props> = ({
|
|||
} col-span-1 ${debuggingWithBackground ? "bg-red-100" : ""}`}
|
||||
>
|
||||
{displayQualityIndicators({
|
||||
numforecasts,
|
||||
lastUpdated,
|
||||
question,
|
||||
showTimeStamp,
|
||||
qualityindicators,
|
||||
platform,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import { FaRegClipboard } from "react-icons/fa";
|
||||
import Link from "next/link";
|
||||
import { FaExpand } from "react-icons/fa";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import { CopyText } from "../../common/CopyText";
|
||||
import { QuestionOptions } from "../../questions/components/QuestionOptions";
|
||||
import { formatProbability } from "../../questions/utils";
|
||||
import { QuestionFragment } from "../../search/queries.generated";
|
||||
import { Card } from "../Card";
|
||||
import { QuestionFooter } from "./QuestionFooter";
|
||||
|
@ -12,7 +16,7 @@ const truncateText = (length: number, text: string): string => {
|
|||
if (!!text && text.length <= length) {
|
||||
return text;
|
||||
}
|
||||
let breakpoints = " .!?";
|
||||
const breakpoints = " .!?";
|
||||
let lastLetter = null;
|
||||
let lastIndex = null;
|
||||
for (let index = length; index > 0; index--) {
|
||||
|
@ -29,17 +33,6 @@ const truncateText = (length: number, text: string): string => {
|
|||
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
|
||||
function escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
|
||||
|
@ -197,63 +190,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 }) => (
|
||||
<div className="flex items-center">
|
||||
<svg className="mt-1" height="10" width="16">
|
||||
|
@ -276,21 +212,19 @@ interface Props {
|
|||
}
|
||||
|
||||
export const DisplayQuestion: React.FC<Props> = ({
|
||||
question: {
|
||||
id,
|
||||
title,
|
||||
url,
|
||||
question,
|
||||
showTimeStamp,
|
||||
expandFooterToFullWidth,
|
||||
showIdToggle,
|
||||
}) => {
|
||||
const {
|
||||
platform,
|
||||
description,
|
||||
options,
|
||||
qualityIndicators,
|
||||
timestamp,
|
||||
visualization,
|
||||
},
|
||||
showTimeStamp,
|
||||
expandFooterToFullWidth,
|
||||
showIdToggle,
|
||||
}) => {
|
||||
} = question;
|
||||
const lastUpdated = new Date(timestamp * 1000);
|
||||
const displayTimestampAtBottom =
|
||||
checkIfDisplayTimeStampAtBottom(qualityIndicators);
|
||||
|
@ -300,92 +234,101 @@ export const DisplayQuestion: React.FC<Props> = ({
|
|||
(options[0].name === "Yes" || options[0].name === "No");
|
||||
|
||||
return (
|
||||
<a className="text‑inherit no-underline" href={url} target="_blank">
|
||||
<Card>
|
||||
<div className="h-full flex flex-col space-y-4">
|
||||
<div className="flex-grow space-y-4">
|
||||
{showIdToggle ? (
|
||||
<div className="mx-10">
|
||||
<CopyText text={id} displayText={`[${id}]`} />
|
||||
</div>
|
||||
) : null}
|
||||
<Card.Title>{title}</Card.Title>
|
||||
{yesNoOptions && (
|
||||
<div className="flex justify-between">
|
||||
<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>
|
||||
<div
|
||||
className={`hidden ${
|
||||
showTimeStamp && !displayTimestampAtBottom ? "sm:block" : ""
|
||||
}`}
|
||||
>
|
||||
<LastUpdated timestamp={lastUpdated} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!yesNoOptions && (
|
||||
<div className="space-y-2">
|
||||
<ForecastOptions options={options} />
|
||||
<div
|
||||
className={`hidden ${
|
||||
showTimeStamp && !displayTimestampAtBottom ? "sm:block" : ""
|
||||
} ml-6`}
|
||||
>
|
||||
<LastUpdated timestamp={lastUpdated} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{platform.id !== "guesstimate" && options.length < 3 && (
|
||||
<div className="text-gray-500">
|
||||
<DisplayMarkdown description={description} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{platform.id === "guesstimate" && (
|
||||
<img
|
||||
className="rounded-sm"
|
||||
src={visualization}
|
||||
alt="Guesstimate Screenshot"
|
||||
/>
|
||||
)}
|
||||
<Card>
|
||||
<div className="h-full flex flex-col space-y-4">
|
||||
<div className="flex-grow space-y-4">
|
||||
{showIdToggle ? (
|
||||
<div className="mx-10">
|
||||
<CopyText text={question.id} displayText={`[${question.id}]`} />
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<Link href={`/questions/${question.id}`}>
|
||||
<a className="float-right block ml-2 mt-1.5">
|
||||
<FaExpand
|
||||
size="18"
|
||||
className="text-gray-400 hover:text-gray-700"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
<Card.Title>
|
||||
<a
|
||||
className="text-black no-underline"
|
||||
href={question.url}
|
||||
target="_blank"
|
||||
>
|
||||
{question.title}
|
||||
</a>
|
||||
</Card.Title>
|
||||
</div>
|
||||
<div
|
||||
className={`sm:hidden ${
|
||||
!showTimeStamp ? "hidden" : ""
|
||||
} self-center`}
|
||||
>
|
||||
{/* This one is exclusively for mobile*/}
|
||||
<LastUpdated timestamp={lastUpdated} />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<QuestionFooter
|
||||
stars={qualityIndicators.stars}
|
||||
platform={platform.id}
|
||||
platformLabel={platform.label}
|
||||
numforecasts={qualityIndicators.numForecasts}
|
||||
qualityindicators={qualityIndicators}
|
||||
lastUpdated={lastUpdated}
|
||||
showTimeStamp={showTimeStamp && displayTimestampAtBottom}
|
||||
expandFooterToFullWidth={expandFooterToFullWidth}
|
||||
{yesNoOptions && (
|
||||
<div className="flex justify-between">
|
||||
<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>
|
||||
<div
|
||||
className={`hidden ${
|
||||
showTimeStamp && !displayTimestampAtBottom ? "sm:block" : ""
|
||||
}`}
|
||||
>
|
||||
<LastUpdated timestamp={lastUpdated} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!yesNoOptions && (
|
||||
<div className="space-y-2">
|
||||
<QuestionOptions options={options} />
|
||||
<div
|
||||
className={`hidden ${
|
||||
showTimeStamp && !displayTimestampAtBottom ? "sm:block" : ""
|
||||
} ml-6`}
|
||||
>
|
||||
<LastUpdated timestamp={lastUpdated} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.platform.id !== "guesstimate" && options.length < 3 && (
|
||||
<div className="text-gray-500">
|
||||
<DisplayMarkdown description={description} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.platform.id === "guesstimate" && (
|
||||
<img
|
||||
className="rounded-sm"
|
||||
src={visualization}
|
||||
alt="Guesstimate Screenshot"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</a>
|
||||
<div
|
||||
className={`sm:hidden ${!showTimeStamp ? "hidden" : ""} self-center`}
|
||||
>
|
||||
{/* This one is exclusively for mobile*/}
|
||||
<LastUpdated timestamp={lastUpdated} />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<QuestionFooter
|
||||
question={question}
|
||||
showTimeStamp={showTimeStamp && displayTimestampAtBottom}
|
||||
expandFooterToFullWidth={expandFooterToFullWidth}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
42
src/web/questions/components/QuestionOptions.tsx
Normal file
42
src/web/questions/components/QuestionOptions.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { formatProbability } from "../utils";
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export const QuestionOptions: 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>
|
||||
);
|
||||
};
|
64
src/web/questions/pages/QuestionPage.tsx
Normal file
64
src/web/questions/pages/QuestionPage.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { GetServerSideProps, NextPage } from "next";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import { Query } from "../../common/Query";
|
||||
import { Card } from "../../display/Card";
|
||||
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>{question.title}</h1>
|
||||
<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;
|
13
src/web/questions/queries.generated.tsx
Normal file
13
src/web/questions/queries.generated.tsx
Normal 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>;
|
5
src/web/questions/queries.graphql
Normal file
5
src/web/questions/queries.graphql
Normal file
|
@ -0,0 +1,5 @@
|
|||
query QuestionById($id: ID!) {
|
||||
result: question(id: $id) {
|
||||
...Question
|
||||
}
|
||||
}
|
10
src/web/questions/utils.ts
Normal file
10
src/web/questions/utils.ts
Normal 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;
|
||||
};
|
Loading…
Reference in New Issue
Block a user