Merge pull request #71 from quantified-uncertainty/question-pages
Fix for upserts, question pages (WIP), history
This commit is contained in:
commit
4d3918629f
|
@ -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**
|
||||||
|
|
|
@ -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");
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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!
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -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,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
@ -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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
|
@ -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']>;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
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";
|
|
@ -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;
|
||||||
|
|
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 cursor-pointer"
|
||||||
|
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>
|
||||||
|
);
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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> */}
|
<span>Forecasts:</span>
|
||||||
<span>{"Forecasts:"}</span>
|
<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>
|
<span>{indicatorLabel}:</span>
|
||||||
<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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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="text‑inherit 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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
|
147
src/web/questions/components/QuestionOptions.tsx
Normal file
147
src/web/questions/components/QuestionOptions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
75
src/web/questions/pages/QuestionPage.tsx
Normal file
75
src/web/questions/pages/QuestionPage.tsx
Normal 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;
|
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