Merge pull request #53 from quantified-uncertainty/forecast-html-css

Frontend refactorings & dashboard features
This commit is contained in:
Vyacheslav Matyukhin 2022-04-12 22:54:22 +03:00 committed by GitHub
commit e320d50276
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1264 additions and 1304 deletions

29
docs/coding-style.md Normal file
View File

@ -0,0 +1,29 @@
# TypeScript
- avoid `any`; get rid of any existing `any` whenever you can so that we can enable `"strict": true` later on in `tsconfig.json`
- define custom types for common data structures
- don't worry about `interface` vs `type`, [both are fine](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#differences-between-type-aliases-and-interfaces)
## Typescript and React/Next
- use `React.FC<Props>` type for React components, e.g. `const MyComponent: React.FC<Props> = ({ ... }) => { ... };`
- use `NextPage<Props>` for typing stuff in `src/pages/`
- use generic versions of `GetServerSideProps<Props>` and `GetStaticProps<Props>`
# React
- create one file per one component (tiny helper components in the same file are fine)
- name file identically to the component it describes (e.g. `const DisplayForecasts: React.FC<Props> = ...` in `DisplayForecasts.ts`)
- use named export instead of default export for all React components
- it's better for refactoring
- and it plays well with `React.FC` typing
# Styles
- use [Tailwind](https://tailwindcss.com/)
- avoid positioning styles in components, position elements from the outside (e.g. with [space-\*](https://tailwindcss.com/docs/space) or grid/flexbox)
# General notes
- use `const` instead of `let` whenever possible
- set up [prettier](https://prettier.io/) to format code on save

30
src/pages/_middleware.ts Normal file
View File

@ -0,0 +1,30 @@
import { NextURL } from "next/dist/server/web/next-url";
import { NextRequest, NextResponse } from "next/server";
export async function middleware(req: NextRequest) {
const { pathname, searchParams } = req.nextUrl;
console.log(pathname);
if (pathname === "/dashboards") {
const dashboardId = searchParams.get("dashboardId");
if (dashboardId) {
return NextResponse.redirect(
new URL(`/dashboards/view/${dashboardId}`, req.url)
);
}
} else if (pathname === "/secretDashboard") {
const dashboardId = searchParams.get("dashboardId");
if (dashboardId) {
const url = new URL(`/dashboards/embed/${dashboardId}`, req.url);
const numCols = searchParams.get("numCols");
if (numCols) {
url.searchParams.set("numCols", numCols);
}
return NextResponse.redirect(url);
} else {
return NextResponse.rewrite(new NextURL("/404", req.url));
}
}
return NextResponse.next();
}

View File

@ -2,9 +2,9 @@ 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 Layout from "../web/display/layout"; import { Layout } from "../web/display/Layout";
let readmeMarkdownText = `# About const readmeMarkdownText = `# About
This webpage is a search engine for probabilities. Given a query, it searches for relevant questions in various prediction markets and forecasting platforms. For example, try searching for "China", "North Korea", "Semiconductors", "COVID", "Trump", or "X-risk". In addition to search, we also provide various [tools](http://localhost:3000/tools). This webpage is a search engine for probabilities. Given a query, it searches for relevant questions in various prediction markets and forecasting platforms. For example, try searching for "China", "North Korea", "Semiconductors", "COVID", "Trump", or "X-risk". In addition to search, we also provide various [tools](http://localhost:3000/tools).

View File

@ -2,7 +2,7 @@ import { NextPage } from "next";
import React from "react"; import React from "react";
import { displayForecastsWrapperForCapture } from "../web/display/displayForecastsWrappers"; import { displayForecastsWrapperForCapture } from "../web/display/displayForecastsWrappers";
import Layout from "../web/display/layout"; import { Layout } from "../web/display/Layout";
import { Props } from "../web/search/anySearchPage"; import { Props } from "../web/search/anySearchPage";
import CommonDisplay from "../web/search/CommonDisplay"; import CommonDisplay from "../web/search/CommonDisplay";

View File

@ -1,210 +0,0 @@
/* Imports */
import axios from "axios";
import { GetServerSideProps, NextPage } from "next";
import { useRouter } from "next/router"; // https://nextjs.org/docs/api-reference/next/router
import { useState } from "react";
import { DashboardItem } from "../backend/dashboards";
import { getPlatformsConfig, PlatformConfig } from "../backend/platforms";
import { DashboardCreator } from "../web/display/dashboardCreator";
import displayForecasts from "../web/display/displayForecasts";
import Layout from "../web/display/layout";
import { addLabelsToForecasts, FrontendForecast } from "../web/platforms";
import { getDashboardForecastsByDashboardId } from "../web/worker/getDashboardForecasts";
interface Props {
initialDashboardForecasts: FrontendForecast[];
initialDashboardId: string | null;
initialDashboardItem: DashboardItem | null;
platformsConfig: PlatformConfig[];
}
export const getServerSideProps: GetServerSideProps<Props> = async (
context
) => {
const dashboardIdQ = context.query.dashboardId;
const dashboardId: string | undefined =
typeof dashboardIdQ === "object" ? dashboardIdQ[0] : dashboardIdQ;
const platformsConfig = getPlatformsConfig({ withGuesstimate: false });
if (!dashboardId) {
return {
props: {
platformsConfig,
initialDashboardForecasts: [],
initialDashboardId: null,
initialDashboardItem: null,
},
};
}
const { dashboardForecasts, dashboardItem } =
await getDashboardForecastsByDashboardId({
dashboardId,
});
const frontendDashboardForecasts = addLabelsToForecasts(
dashboardForecasts,
platformsConfig
);
return {
props: {
initialDashboardForecasts: frontendDashboardForecasts,
initialDashboardId: dashboardId,
initialDashboardItem: dashboardItem,
platformsConfig,
},
};
};
/* Body */
const DashboardsPage: NextPage<Props> = ({
initialDashboardForecasts,
initialDashboardItem,
platformsConfig,
}) => {
const router = useRouter();
const [dashboardForecasts, setDashboardForecasts] = useState(
initialDashboardForecasts
);
const [dashboardItem, setDashboardItem] = useState(initialDashboardItem);
let handleSubmit = async (data) => {
console.log(data);
// Send to server to create
// Get back the id
let response = await axios({
url: `/api/create-dashboard-from-ids`,
method: "POST",
headers: { "Content-Type": "application/json" },
data: JSON.stringify(data),
}).then((res) => res.data);
let dashboardId = response.dashboardId;
if (!!dashboardId) {
console.log("response: ", response);
if (typeof window !== "undefined") {
let urlWithoutDefaultParameters = `/dashboards?dashboardId=${dashboardId}`;
if (!window.location.href.includes(urlWithoutDefaultParameters)) {
window.history.replaceState(
null,
"Metaforecast",
urlWithoutDefaultParameters
);
}
}
// router.push(`?dashboardId=${dashboardId}`)
// display it
let { dashboardForecasts, dashboardItem } =
await getDashboardForecastsByDashboardId({
dashboardId,
});
setDashboardForecasts(
addLabelsToForecasts(dashboardForecasts, platformsConfig)
);
setDashboardItem(dashboardItem);
}
};
let isGraubardEasterEgg = (name) => (name == "Clay Graubard" ? true : false);
return (
<Layout page="dashboard">
{/* Display forecasts */}
<div className="mt-7 mb-7">
<h1
className={
!!dashboardItem && !!dashboardItem.title
? "text-4xl text-center text-gray-600 mt-2 mb-2"
: "hidden"
}
>
{!!dashboardItem ? dashboardItem.title : ""}
</h1>
<p
className={
!!dashboardItem &&
!!dashboardItem.creator &&
!isGraubardEasterEgg(dashboardItem.creator)
? "text-lg text-center text-gray-600 mt-2 mb-2"
: "hidden"
}
>
{!!dashboardItem ? `Created by: ${dashboardItem.creator}` : ""}
</p>
<p
className={
!!dashboardItem &&
!!dashboardItem.creator &&
isGraubardEasterEgg(dashboardItem.creator)
? "text-lg text-center text-gray-600 mt-2 mb-2"
: "hidden"
}
>
{!!dashboardItem ? `Created by: @` : ""}
<a
href={"https://twitter.com/ClayGraubard"}
className="text-blue-600"
>
Clay Graubard
</a>
</p>
<p
className={
!!dashboardItem && !!dashboardItem.description
? "text-lg text-center text-gray-600 mt-2 mb-2"
: "hidden"
}
>
{!!dashboardItem ? `${dashboardItem.description}` : ""}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{displayForecasts({
results: dashboardForecasts,
numDisplay: dashboardForecasts.length,
showIdToggle: false,
})}
</div>
{/* */}
<h3 className="flex items-center col-start-2 col-end-2 w-full justify-center mt-8 mb-4">
<span
aria-hidden="true"
className="flex-grow bg-gray-300 rounded h-0.5"
></span>
<span
className={
!!dashboardForecasts && dashboardForecasts.length > 0
? `mx-3 text-md font-medium text-center`
: "hidden"
}
>
Or create your own
</span>
<span
className={
!dashboardForecasts || dashboardForecasts.length == 0
? `mx-3 text-md font-medium text-center`
: "hidden"
}
>
Create a dashboard!
</span>
<span
aria-hidden="true"
className="flex-grow bg-gray-300 rounded h-0.5"
></span>
</h3>
<div className="grid grid-cols-3 justify-center">
<div className="flex col-start-2 col-end-2 items-center justify-center">
<DashboardCreator handleSubmit={handleSubmit} />
</div>
</div>
</Layout>
);
};
export default DashboardsPage;

View File

@ -0,0 +1,69 @@
import { GetServerSideProps, NextPage } from "next";
import Error from "next/error";
import { DashboardItem } from "../../../backend/dashboards";
import { DisplayForecasts } from "../../../web/display/DisplayForecasts";
import { FrontendForecast } from "../../../web/platforms";
import { getDashboardForecastsByDashboardId } from "../../../web/worker/getDashboardForecasts";
interface Props {
dashboardForecasts: FrontendForecast[];
dashboardItem: DashboardItem;
numCols?: number;
}
export const getServerSideProps: GetServerSideProps<Props> = async (
context
) => {
const dashboardId = context.query.id as string;
const numCols = Number(context.query.numCols);
const { dashboardItem, dashboardForecasts } =
await getDashboardForecastsByDashboardId({
dashboardId,
});
if (!dashboardItem) {
context.res.statusCode = 404;
}
return {
props: {
dashboardForecasts,
dashboardItem,
numCols: !numCols ? null : numCols < 5 ? numCols : 4,
},
};
};
const EmbedDashboardPage: NextPage<Props> = ({
dashboardForecasts,
dashboardItem,
numCols,
}) => {
if (!dashboardItem) {
return <Error statusCode={404} />;
}
return (
<div className="mb-4 mt-3 flex flex-row justify-left items-center">
<div className="mx-2 place-self-left">
<div
className={`grid grid-cols-${numCols || 1} sm:grid-cols-${
numCols || 1
} md:grid-cols-${numCols || 2} lg:grid-cols-${
numCols || 3
} gap-4 mb-6`}
>
<DisplayForecasts
results={dashboardForecasts}
numDisplay={dashboardForecasts.length}
showIdToggle={false}
/>
</div>
</div>
</div>
);
};
export default EmbedDashboardPage;

View File

@ -0,0 +1,37 @@
import axios from "axios";
import { NextPage } from "next";
import { useRouter } from "next/router";
import { DashboardCreator } from "../../web/display/DashboardCreator";
import { Layout } from "../../web/display/Layout";
import { LineHeader } from "../../web/display/LineHeader";
const DashboardsPage: NextPage = () => {
const router = useRouter();
const handleSubmit = async (data) => {
// Send to server to create
// Get back the id
let response = await axios({
url: "/api/create-dashboard-from-ids",
method: "POST",
headers: { "Content-Type": "application/json" },
data: JSON.stringify(data),
}).then((res) => res.data);
await router.push(`/dashboards/view/${response.dashboardId}`);
};
return (
<Layout page="dashboard">
<div className="flex flex-col my-8 space-y-8">
<LineHeader>Create a dashboard!</LineHeader>
<div className="self-center">
<DashboardCreator handleSubmit={handleSubmit} />
</div>
</div>
</Layout>
);
};
export default DashboardsPage;

View File

@ -0,0 +1,115 @@
import { GetServerSideProps, NextPage } from "next";
import Error from "next/error";
import Link from "next/link";
import { DashboardItem } from "../../../backend/dashboards";
import { DisplayForecasts } from "../../../web/display/DisplayForecasts";
import { InfoBox } from "../../../web/display/InfoBox";
import { Layout } from "../../../web/display/Layout";
import { LineHeader } from "../../../web/display/LineHeader";
import { FrontendForecast } from "../../../web/platforms";
import { getDashboardForecastsByDashboardId } from "../../../web/worker/getDashboardForecasts";
interface Props {
dashboardForecasts: FrontendForecast[];
dashboardItem: DashboardItem;
}
export const getServerSideProps: GetServerSideProps<Props> = async (
context
) => {
const dashboardId = context.query.id as string;
const { dashboardForecasts, dashboardItem } =
await getDashboardForecastsByDashboardId({
dashboardId,
});
if (!dashboardItem) {
context.res.statusCode = 404;
}
return {
props: {
dashboardForecasts,
dashboardItem,
},
};
};
const DashboardMetadata: React.FC<{ dashboardItem: DashboardItem }> = ({
dashboardItem,
}) => (
<div>
{dashboardItem?.title ? (
<h1 className="text-4xl text-center text-gray-600 mt-2 mb-2">
{dashboardItem.title}
</h1>
) : null}
{dashboardItem && dashboardItem.creator ? (
<p className="text-lg text-center text-gray-600 mt-2 mb-2">
Created by:{" "}
{dashboardItem.creator === "Clay Graubard" ? (
<>
@
<a
href="https://twitter.com/ClayGraubard"
className="text-blue-600"
>
Clay Graubard
</a>
</>
) : (
dashboardItem.creator
)}
</p>
) : null}
{dashboardItem?.description ? (
<p className="text-lg text-center text-gray-600 mt-2 mb-2">
{dashboardItem.description}
</p>
) : null}
</div>
);
/* Body */
const ViewDashboardPage: NextPage<Props> = ({
dashboardForecasts,
dashboardItem,
}) => {
return (
<Layout page="view-dashboard">
<div className="flex flex-col my-8 space-y-8">
{dashboardItem ? (
<DashboardMetadata dashboardItem={dashboardItem} />
) : (
<Error statusCode={404} />
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<DisplayForecasts
results={dashboardForecasts}
numDisplay={dashboardForecasts.length}
showIdToggle={false}
/>
</div>
<div className="max-w-xl self-center">
<InfoBox>
Dashboards cannot be changed after they are created.
</InfoBox>
</div>
<LineHeader>
<Link href="/dashboards" passHref>
<a>Create your own dashboard</a>
</Link>
</LineHeader>
</div>
</Layout>
);
};
export default ViewDashboardPage;

View File

@ -2,7 +2,7 @@ import { NextPage } from "next";
import React from "react"; import React from "react";
import { displayForecastsWrapperForSearch } from "../web/display/displayForecastsWrappers"; import { displayForecastsWrapperForSearch } from "../web/display/displayForecastsWrappers";
import Layout from "../web/display/layout"; import { Layout } from "../web/display/Layout";
import { Props } from "../web/search/anySearchPage"; import { Props } from "../web/search/anySearchPage";
import CommonDisplay from "../web/search/CommonDisplay"; import CommonDisplay from "../web/search/CommonDisplay";

View File

@ -1,6 +1,7 @@
import { NextPage } from "next";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
function Recursion() { const Recursion: NextPage = () => {
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.location.href = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; window.location.href = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
@ -12,6 +13,6 @@ function Recursion() {
<h2>You have now reached the fourth level of recursion!!</h2> <h2>You have now reached the fourth level of recursion!!</h2>
</div> </div>
); );
} };
export default Recursion; export default Recursion;

View File

@ -1,100 +0,0 @@
/* Imports */
// React
import { useRouter } from "next/router"; // https://nextjs.org/docs/api-reference/next/router
import { useState } from "react";
import { getPlatformsConfig } from "../backend/platforms";
import displayForecasts from "../web/display/displayForecasts";
import { addLabelsToForecasts } from "../web/platforms";
import { getDashboardForecastsByDashboardId } from "../web/worker/getDashboardForecasts";
/* get Props */
export async function getServerSideProps(context) {
console.log("getServerSideProps: ");
let urlQuery = context.query;
console.log(urlQuery);
let dashboardId = urlQuery.dashboardId;
let numCols = urlQuery.numCols;
let props;
if (!!dashboardId) {
console.log(dashboardId);
let { dashboardForecasts, dashboardItem } =
await getDashboardForecastsByDashboardId({
dashboardId,
});
dashboardForecasts = addLabelsToForecasts(
dashboardForecasts,
getPlatformsConfig({ withGuesstimate: false })
);
props = {
initialDashboardForecasts: dashboardForecasts,
initialDashboardId: urlQuery.dashboardId,
initialDashboardItem: dashboardItem,
initialNumCols: !numCols ? null : numCols < 5 ? numCols : 4,
};
} else {
console.log();
props = {
initialDashboardForecasts: [],
initialDashboardId: urlQuery.dashboardId || null,
initialDashboardItem: null,
initialNumCols: !numCols ? null : numCols < 5 ? numCols : 4,
};
}
return {
props: props,
};
/*
let dashboardforecasts = await getdashboardforecasts({
ids: ["metaculus-6526", "smarkets-20206424"],
});
let props = {
dashboardforecasts: dashboardforecasts,
};
return {
props: props,
};
*/
}
/* Body */
export default function Home({
initialDashboardForecasts,
initialDashboardItem,
initialNumCols,
}) {
const router = useRouter();
const [dashboardForecasts, setDashboardForecasts] = useState(
initialDashboardForecasts
);
const [dashboardItem, setDashboardItem] = useState(initialDashboardItem);
const [numCols, setNumCols] = useState(initialNumCols);
console.log("initialNumCols", initialNumCols);
// <div className={`grid ${!!numCols ? `grid-cols-${numCols} md:grid-cols-${numCols} lg:grid-cols-${numCols}`: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3"} gap-4 mb-6`}>
// <div className={`grid grid-cols-${numCols || 1} md:grid-cols-${numCols || 2} lg:grid-cols-${numCols || 3} gap-4 mb-6`}>
return (
<div className="mb-4 mt-3 flex flex-row justify-left items-center ">
<div className="ml-2 mr-2 place-self-left">
<div
className={`grid grid-cols-${numCols || 1} sm:grid-cols-${
numCols || 1
} md:grid-cols-${numCols || 2} lg:grid-cols-${
numCols || 3
} gap-4 mb-6`}
>
{displayForecasts({
results: dashboardForecasts,
numDisplay: dashboardForecasts.length,
showIdToggle: false,
})}
</div>
</div>
</div>
);
}

View File

@ -4,7 +4,7 @@ import { GetServerSideProps, NextPage } from "next";
import React from "react"; import React from "react";
import { platforms } from "../backend/platforms"; import { platforms } from "../backend/platforms";
import { DisplayForecast } from "../web/display/displayForecasts"; import { DisplayForecast } from "../web/display/DisplayForecast";
import { FrontendForecast } from "../web/platforms"; import { FrontendForecast } from "../web/platforms";
import searchAccordingToQueryData from "../web/worker/searchAccordingToQueryData"; import searchAccordingToQueryData from "../web/worker/searchAccordingToQueryData";

View File

@ -1,85 +1,76 @@
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";
import Layout from "../web/display/layout"; import { Card } from "../web/display/Card";
import { Layout } from "../web/display/Layout";
type AnyTool = {
title: string;
description: string;
img?: string;
};
type InnerTool = AnyTool & { innerLink: string };
type ExternalTool = AnyTool & { externalLink: string };
type UpcomingTool = AnyTool;
type Tool = InnerTool | ExternalTool | UpcomingTool;
/* Display one tool */ /* Display one tool */
function displayTool({ const ToolCard: React.FC<Tool> = (tool) => {
sameWebpage, const inner = (
title, <Card>
description, <div className="grid content-start gap-3">
link, <div className="text-gray-800 text-lg font-medium">{tool.title}</div>
url, <div className="text-gray-500">{tool.description}</div>
img, {tool.img && <img src={tool.img} className="text-gray-500" />}
i, </div>
}: any) { </Card>
switch (sameWebpage) { );
case true:
if ("innerLink" in tool) {
return ( return (
<Link href={link} passHref key={`tool-${i}`}> <Link href={tool.innerLink} passHref>
<div className="hover:bg-gray-100 hover:no-underline cursor-pointer flex flex-col px-4 py-3 bg-white rounded-md shadow place-content-stretch flex-grow no-underline b-6"> <a className="textinherit no-underline">{inner}</a>
<div className="flex-grow items-stretch">
<div className={`text-gray-800 text-lg mb-2 font-medium `}>
{title}
</div>
<div className={`text-gray-500 mb-3 `}>{description}</div>
{}
<img src={img} className={`text-gray-500 mb-2`} />
</div>
</div>
</Link> </Link>
); );
break; } else if ("externalLink" in tool) {
default:
return ( return (
<a <a href={tool.externalLink} className="textinherit no-underline">
href={url} {inner}
key={`tool-${i}`}
className="hover:bg-gray-100 hover:no-underline cursor-pointer flex flex-col px-4 py-3 bg-white rounded-md shadow place-content-stretch flex-grow no-underline b-6"
>
<div className="flex-grow items-stretch">
<div className={`text-gray-800 text-lg mb-2 font-medium `}>
{title}
</div>
<div className={`text-gray-500 mb-3 `}>{description}</div>
{}
<img src={img} className={`text-gray-500 mb-2`} />
</div>
</a> </a>
); );
break; } else {
return inner;
} }
} };
export default function Tools({ lastUpdated }) { export default function Tools({ lastUpdated }) {
let tools = [ let tools: Tool[] = [
{ {
title: "Search", title: "Search",
description: "Find forecasting questions on many platforms", description: "Find forecasting questions on many platforms.",
link: "/", innerLink: "/",
sameWebpage: true,
img: "https://i.imgur.com/Q94gVqG.png", img: "https://i.imgur.com/Q94gVqG.png",
}, },
{ {
title: "[Beta] Present", title: "[Beta] Present",
description: "Present forecasts in dashboards.", description: "Present forecasts in dashboards.",
sameWebpage: true, innerLink: "/dashboards",
link: "/dashboards",
img: "https://i.imgur.com/x8qkuHQ.png", img: "https://i.imgur.com/x8qkuHQ.png",
}, },
{ {
title: "Capture", title: "Capture",
description: description:
"Capture forecasts save them to Imgur. Useful for posting them somewhere else as images. Currently rate limited by Imgur, so if you get a .gif of a fox falling flat on his face, that's why.", "Capture forecasts save them to Imgur. Useful for posting them somewhere else as images. Currently rate limited by Imgur, so if you get a .gif of a fox falling flat on his face, that's why.",
link: "/capture", innerLink: "/capture",
sameWebpage: true,
img: "https://i.imgur.com/EXkFBzz.png", img: "https://i.imgur.com/EXkFBzz.png",
}, },
{ {
title: "Summon", title: "Summon",
description: description:
"Summon metaforecast on Twitter by mentioning @metaforecast, or on Discord by using Fletcher and !metaforecast, followed by search terms", "Summon metaforecast on Twitter by mentioning @metaforecast, or on Discord by using Fletcher and !metaforecast, followed by search terms.",
url: "https://twitter.com/metaforecast", externalLink: "https://twitter.com/metaforecast",
img: "https://i.imgur.com/BQ4Zzjw.png", img: "https://i.imgur.com/BQ4Zzjw.png",
}, },
{ {
@ -87,7 +78,6 @@ export default function Tools({ lastUpdated }) {
description: description:
"Interact with metaforecast's API and fetch forecasts for your application. Currently possible but documentation is poor, get in touch.", "Interact with metaforecast's API and fetch forecasts for your application. Currently possible but documentation is poor, get in touch.",
}, },
{ {
title: "[Upcoming] Record", title: "[Upcoming] Record",
description: "Save your forecasts or bets.", description: "Save your forecasts or bets.",
@ -95,8 +85,10 @@ export default function Tools({ lastUpdated }) {
]; ];
return ( return (
<Layout page="tools"> <Layout page="tools">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-4 mb-8"> <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) => displayTool({ ...tool, i }))} {tools.map((tool, i) => (
<ToolCard {...tool} key={`tool-${i}`} />
))}
</div> </div>
</Layout> </Layout>
); );

View File

@ -0,0 +1,10 @@
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
export const Button: React.FC<Props> = ({ children, ...rest }) => (
<button
{...rest}
className="bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent rounded text-center"
>
{children}
</button>
);

View File

@ -5,11 +5,11 @@ interface Props {
value: number; value: number;
} }
const ButtonsForStars: React.FC<Props> = ({ onChange, value }) => { export const ButtonsForStars: React.FC<Props> = ({ onChange, value }) => {
const onChangeInner = (buttonPressed: number) => { const onChangeInner = (buttonPressed: number) => {
onChange(buttonPressed); onChange(buttonPressed);
}; };
let setStyle = (buttonNumber: number) => const setStyle = (buttonNumber: number) =>
`flex row-span-1 col-start-${buttonNumber + 1} col-end-${ `flex row-span-1 col-start-${buttonNumber + 1} col-end-${
buttonNumber + 2 buttonNumber + 2
} items-center justify-center text-center${ } items-center justify-center text-center${
@ -37,5 +37,3 @@ const ButtonsForStars: React.FC<Props> = ({ onChange, value }) => {
</div> </div>
); );
}; };
export default ButtonsForStars;

15
src/web/display/Card.tsx Normal file
View File

@ -0,0 +1,15 @@
const CardTitle: React.FC = ({ children }) => (
<div className="text-gray-800 text-lg font-medium">{children}</div>
);
type CardType = React.FC & {
Title: typeof CardTitle;
};
export const Card: CardType = ({ children }) => (
<div className="h-full px-4 py-3 bg-white hover:bg-gray-100 rounded-md shadow">
{children}
</div>
);
Card.Title = CardTitle;

View File

@ -0,0 +1,69 @@
import React, { EventHandler, SyntheticEvent, useState } from "react";
import { Button } from "./Button";
import { InfoBox } from "./InfoBox";
const exampleInput = `{
"title": "Random example",
"description": "Just a random description of a random example",
"ids": [ "metaculus-372", "goodjudgmentopen-2244", "metaculus-7550", "kalshi-09d060ee-b184-4167-b86b-d773e56b4162", "wildeford-5d1a04e1a8", "metaculus-2817" ],
"creator": "Peter Parker"
}`;
interface Props {
handleSubmit: (data: any) => Promise<void>;
}
export const DashboardCreator: React.FC<Props> = ({ handleSubmit }) => {
const [value, setValue] = useState(exampleInput);
const [acting, setActing] = useState(false);
const handleChange = (event) => {
setValue(event.target.value);
};
const handleSubmitInner: EventHandler<SyntheticEvent> = async (event) => {
event.preventDefault();
try {
const newData = JSON.parse(value);
if (!newData || !newData.ids || newData.ids.length == 0) {
throw Error("Not enough objects");
} else {
setActing(true);
await handleSubmit(newData);
setActing(false);
}
} catch (error) {
setActing(false);
const substituteText = `Error: ${error.message}
Try something like:
${exampleInput}
Your old input was: ${value}`;
setValue(substituteText);
}
};
return (
<form onSubmit={handleSubmitInner}>
<div className="flex flex-col items-center space-y-5 max-w-2xl">
<textarea value={value} onChange={handleChange} rows={8} cols={50} />
<Button
disabled={acting}
onClick={acting ? undefined : handleSubmitInner}
>
{acting ? "Creating..." : "Create dashboard"}
</Button>
<InfoBox>
You can find the necessary ids by toggling the advanced options in the
search, or by visiting{" "}
<a href="/api/all-forecasts">/api/all-forecasts</a>
</InfoBox>
</div>
</form>
);
};

View File

@ -0,0 +1,299 @@
const formatQualityIndicator = (indicator) => {
let result;
switch (indicator) {
case "numforecasts":
result = null;
break;
case "stars":
result = null;
break;
case "volume":
result = "Volume";
break;
case "numforecasters":
result = "Forecasters";
break;
case "yes_bid":
result = null; // "Yes bid"
break;
case "yes_ask":
result = null; // "Yes ask"
break;
case "spread":
result = "Spread";
break;
case "shares_volume":
result = "Shares vol.";
break;
case "open_interest":
result = "Interest";
break;
case "resolution_data":
result = null;
break;
case "liquidity":
result = "Liquidity";
break;
case "tradevolume":
result = "Volume";
break;
}
return result;
};
const formatNumber = (num) => {
if (Number(num) < 1000) {
return Number(num).toFixed(0);
} else if (num < 10000) {
return (Number(num) / 1000).toFixed(1) + "k";
} else {
return (Number(num) / 1000).toFixed(0) + "k";
}
};
const formatQualityIndicators = (qualityIndicators: any) => {
let newQualityIndicators = {};
for (let key in qualityIndicators) {
let newKey = formatQualityIndicator(key);
if (newKey) {
newQualityIndicators[newKey] = qualityIndicators[key];
}
}
return newQualityIndicators;
};
/* Display functions*/
const getPercentageSymbolIfNeeded = ({ indicator, platform }) => {
let indicatorsWhichNeedPercentageSymbol = ["Spread"];
if (indicatorsWhichNeedPercentageSymbol.includes(indicator)) {
return "%";
} else {
return "";
}
};
const getCurrencySymbolIfNeeded = ({
indicator,
platform,
}: {
indicator: any;
platform: string;
}) => {
let indicatorsWhichNeedCurrencySymbol = ["Volume", "Interest", "Liquidity"];
let dollarPlatforms = ["predictit", "kalshi", "polymarket"];
if (indicatorsWhichNeedCurrencySymbol.includes(indicator)) {
if (dollarPlatforms.includes(platform)) {
return "$";
} else {
return "£";
}
} else {
return "";
}
};
const showFirstQualityIndicator = ({
numforecasts,
timestamp,
showTimeStamp,
qualityindicators,
}) => {
if (!!numforecasts) {
return (
<div className="flex col-span-1 row-span-1">
{/*<span>{` ${numforecasts == 1 ? "Forecast" : "Forecasts:"}`}</span>&nbsp;*/}
<span>{"Forecasts:"}</span>&nbsp;
<span className="font-bold">{Number(numforecasts).toFixed(0)}</span>
</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: ${
timestamp && !!timestamp.slice ? timestamp.slice(0, 10) : "unknown"
}`}
</span>
);
} else {
return null;
}
};
const displayQualityIndicators: React.FC<{
numforecasts: number;
timestamp: number;
showTimeStamp: boolean;
qualityindicators: any;
platform: string; // id string - e.g. "goodjudgment", not "Good Judgment"
}> = ({
numforecasts,
timestamp,
showTimeStamp,
qualityindicators,
platform,
}) => {
// grid grid-cols-1
return (
<div className="text-sm">
{showFirstQualityIndicator({
numforecasts,
timestamp,
showTimeStamp,
qualityindicators,
})}
{Object.entries(formatQualityIndicators(qualityindicators)).map(
(entry, i) => {
return (
<div className="col-span-1 row-span-1">
<span>{`${entry[0]}:`}</span>&nbsp;
<span className="font-bold">
{`${getCurrencySymbolIfNeeded({
indicator: entry[0],
platform,
})}${formatNumber(entry[1])}${getPercentageSymbolIfNeeded({
indicator: entry[0],
platform,
})}`}
</span>
</div>
);
}
)}
</div>
);
};
// Database-like functions
export function getstars(numstars: number) {
let stars = "★★☆☆☆";
switch (numstars) {
case 0:
stars = "☆☆☆☆☆";
break;
case 1:
stars = "★☆☆☆☆";
break;
case 2:
stars = "★★☆☆☆";
break;
case 3:
stars = "★★★☆☆";
break;
case 4:
stars = "★★★★☆";
break;
case 5:
stars = "★★★★★";
break;
default:
stars = "★★☆☆☆";
}
return stars;
}
function getStarsColor(numstars: number) {
let color = "text-yellow-400";
switch (numstars) {
case 0:
color = "text-red-400";
break;
case 1:
color = "text-red-400";
break;
case 2:
color = "text-orange-400";
break;
case 3:
color = "text-yellow-400";
break;
case 4:
color = "text-green-400";
break;
case 5:
color = "text-blue-400";
break;
default:
color = "text-yellow-400";
}
return color;
}
interface Props {
stars: any;
platform: string;
platformLabel: string;
numforecasts: any;
qualityindicators: any;
timestamp: any;
showTimeStamp: boolean;
expandFooterToFullWidth: boolean;
}
export const ForecastFooter: React.FC<Props> = ({
stars,
platform,
platformLabel,
numforecasts,
qualityindicators,
timestamp,
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
className={`grid grid-cols-3 ${
expandFooterToFullWidth ? "justify-between" : ""
} text-gray-500 mb-2 mt-1`}
>
<div
className={`self-center col-span-1 ${getStarsColor(stars)} ${
debuggingWithBackground ? "bg-red-200" : ""
}`}
>
{getstars(stars)}
</div>
<div
className={`${
expandFooterToFullWidth ? "place-self-center" : "self-center"
} col-span-1 font-bold ${debuggingWithBackground ? "bg-red-100" : ""}`}
>
{platformLabel
.replace("Good Judgment Open", "GJOpen")
.replace(/ /g, "\u00a0")}
</div>
<div
className={`${
expandFooterToFullWidth
? "justify-self-end mr-4"
: "justify-self-center"
} col-span-1 ${debuggingWithBackground ? "bg-red-100" : ""}`}
>
{displayQualityIndicators({
numforecasts,
timestamp,
showTimeStamp,
qualityindicators,
platform,
})}
</div>
</div>
);
};

View File

@ -0,0 +1,390 @@
import { FaRegClipboard } from "react-icons/fa";
import ReactMarkdown from "react-markdown";
import { FrontendForecast } from "../../platforms";
import { Card } from "../Card";
import { ForecastFooter } from "./ForecastFooter";
const truncateText = (length: number, text: string): string => {
if (!text) {
return "";
}
if (!!text && text.length <= length) {
return text;
}
let breakpoints = " .!?";
let lastLetter = null;
let lastIndex = null;
for (let index = length; index > 0; index--) {
let letter = text[index];
if (breakpoints.includes(letter)) {
lastLetter = letter;
lastIndex = index;
break;
}
}
let truncatedText = !!text.slice
? text.slice(0, lastIndex) + (lastLetter != "." ? "..." : "..")
: "";
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
}
function replaceAll(
originalString: string,
pattern: string | RegExp,
substitute
) {
return originalString.replace(
new RegExp(escapeRegExp(pattern), "g"),
substitute
);
}
if (!String.prototype.replaceAll) {
String.prototype.replaceAll = function (
pattern: string | RegExp,
substitute
) {
let originalString = this;
// If a regex pattern
if (
Object.prototype.toString.call(pattern).toLowerCase() ===
"[object regexp]"
) {
return originalString.replace(pattern, substitute);
}
// If a string
return replaceAll(originalString, pattern, substitute);
};
}
const cleanText = (text: string): string => {
// Note: should no longer be necessary
let textString = !!text ? text : "";
textString = textString
.replaceAll("] (", "](")
.replaceAll(") )", "))")
.replaceAll("( [", "([")
.replaceAll(") ,", "),")
.replaceAll("==", "") // Denotes a title in markdown
.replaceAll("Background\n", "")
.replaceAll("Context\n", "")
.replaceAll("--- \n", "- ")
.replaceAll(/\[(.*?)\]\(.*?\)/g, "$1");
textString = textString.slice(0, 1) == "=" ? textString.slice(1) : textString;
//console.log(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
const DisplayMarkdown: React.FC<{ description: string }> = ({
description,
}) => {
let formatted = truncateText(250, cleanText(description));
// overflow-hidden overflow-ellipsis h-24
return formatted === "" ? null : (
<div className="overflow-clip">
<ReactMarkdown linkTarget="_blank" className="font-normal">
{formatted}
</ReactMarkdown>
</div>
);
};
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: string }> = ({ timestamp }) => (
<div className="flex items-center">
<svg className="mt-1" height="10" width="16">
<circle cx="4" cy="4" r="4" fill="rgb(29, 78, 216)" />
</svg>
<span className="text-gray-600">
Last updated: {timestamp ? timestamp.slice(0, 10) : "unknown"}
</span>
</div>
);
// Main component
interface Props {
forecast: FrontendForecast;
showTimeStamp: boolean;
expandFooterToFullWidth: boolean;
showIdToggle?: boolean;
}
export const DisplayForecast: React.FC<Props> = ({
forecast: {
id,
title,
url,
platform,
platformLabel,
description,
options,
qualityindicators,
timestamp,
visualization,
},
showTimeStamp,
expandFooterToFullWidth,
showIdToggle,
}) => {
const displayTimestampAtBottom =
checkIfDisplayTimeStampAtBottom(qualityindicators);
const yesNoOptions =
options.length === 2 &&
(options[0].name === "Yes" || options[0].name === "No");
return (
<a className="textinherit 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={timestamp} />
</div>
</div>
)}
{!yesNoOptions && (
<div className="space-y-2">
<ForecastOptions options={options} />
<div
className={`hidden ${
showTimeStamp && !displayTimestampAtBottom ? "sm:block" : ""
} ml-6`}
>
<LastUpdated timestamp={timestamp} />
</div>
</div>
)}
{platform !== "guesstimate" && options.length < 3 && (
<div className="text-gray-500">
<DisplayMarkdown description={description} />
</div>
)}
{platform === "guesstimate" && (
<img
className="rounded-sm"
src={visualization}
alt="Guesstimate Screenshot"
/>
)}
</div>
<div
className={`sm:hidden ${
!showTimeStamp ? "hidden" : ""
} self-center`}
>
{/* This one is exclusively for mobile*/}
<LastUpdated timestamp={timestamp} />
</div>
<div className="w-full">
<ForecastFooter
stars={qualityindicators.stars}
platform={platform}
platformLabel={platformLabel || platform} // author || platformLabel,
numforecasts={qualityindicators.numforecasts}
qualityindicators={qualityindicators}
timestamp={timestamp}
showTimeStamp={showTimeStamp && displayTimestampAtBottom}
expandFooterToFullWidth={expandFooterToFullWidth}
/>
</div>
</div>
</Card>
</a>
);
};

View File

@ -0,0 +1,38 @@
import React from "react";
import { FrontendForecast } from "../platforms";
import { DisplayForecast } from "./DisplayForecast";
interface Props {
results: FrontendForecast[];
numDisplay: number;
showIdToggle: boolean;
}
export const DisplayForecasts: React.FC<Props> = ({
results,
numDisplay,
showIdToggle,
}) => {
if (!results) {
return <></>;
}
return (
<>
{results.slice(0, numDisplay).map((result) => (
/*let displayWithMetaculusCapture =
fuseSearchResult.item.platform == "Metaculus"
? metaculusEmbed(fuseSearchResult.item)
: displayForecast({ ...fuseSearchResult.item });
*/
<DisplayForecast
key={result.id}
forecast={result}
showTimeStamp={false}
expandFooterToFullWidth={false}
showIdToggle={showIdToggle}
/>
))}
</>
);
};

View File

@ -4,7 +4,7 @@ import { CopyToClipboard } from "react-copy-to-clipboard";
import { FrontendForecast } from "../platforms"; import { FrontendForecast } from "../platforms";
import { uploadToImgur } from "../worker/uploadToImgur"; import { uploadToImgur } from "../worker/uploadToImgur";
import { DisplayForecast } from "./displayForecasts"; import { DisplayForecast } from "./DisplayForecast";
function displayOneForecastInner(result: FrontendForecast, containerRef) { function displayOneForecastInner(result: FrontendForecast, containerRef) {
return ( return (
@ -171,7 +171,7 @@ interface Props {
result: FrontendForecast; result: FrontendForecast;
} }
const DisplayOneForecast: React.FC<Props> = ({ result }) => { export const DisplayOneForecastForCapture: React.FC<Props> = ({ result }) => {
const [hasDisplayBeenCaptured, setHasDisplayBeenCaptured] = useState(false); const [hasDisplayBeenCaptured, setHasDisplayBeenCaptured] = useState(false);
useEffect(() => { useEffect(() => {
@ -248,8 +248,6 @@ const DisplayOneForecast: React.FC<Props> = ({ result }) => {
); );
}; };
export default DisplayOneForecast;
// https://stackoverflow.com/questions/39501289/in-reactjs-how-to-copy-text-to-clipboard // https://stackoverflow.com/questions/39501289/in-reactjs-how-to-copy-text-to-clipboard
// Note: https://stackoverflow.com/questions/66016033/can-no-longer-upload-images-to-imgur-from-localhost // Note: https://stackoverflow.com/questions/66016033/can-no-longer-upload-images-to-imgur-from-localhost
// Use: http://imgurtester:3000/embed for testing. // Use: http://imgurtester:3000/embed for testing.

View File

@ -0,0 +1,5 @@
export const InfoBox: React.FC = ({ children }) => (
<p className="bg-gray-200 text-gray-700 py-2 px-4 border border-transparent text-center">
{children}
</p>
);

View File

@ -67,7 +67,7 @@ class ErrorBoundary extends React.Component<
} }
/* Main */ /* Main */
export default function Layout({ page, children }) { export const Layout = ({ 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.
@ -150,4 +150,4 @@ export default function Layout({ page, children }) {
</div> </div>
</div> </div>
); );
} };

View File

@ -0,0 +1,7 @@
export const LineHeader: React.FC = ({ children }) => (
<h3 className="flex items-center justify-center w-full">
<span aria-hidden="true" className="flex-grow bg-gray-300 rounded h-0.5" />
<span className="mx-3 text-md font-medium text-center">{children}</span>
<span aria-hidden="true" className="flex-grow bg-gray-300 rounded h-0.5" />
</h3>
);

View File

@ -1,6 +1,16 @@
import React from "react"; import React from "react";
export default function Form({ value, onChange, placeholder }) { interface Props {
value: string;
onChange: (v: string) => void;
placeholder: string;
}
export const QueryForm: React.FC<Props> = ({
value,
onChange,
placeholder,
}) => {
const handleInputChange = (event) => { const handleInputChange = (event) => {
event.preventDefault(); event.preventDefault();
onChange(event.target.value); // In this case, the query, e.g. "COVID.19" onChange(event.target.value); // In this case, the query, e.g. "COVID.19"
@ -21,4 +31,4 @@ export default function Form({ value, onChange, placeholder }) {
/> />
</form> </form>
); );
} };

View File

@ -76,9 +76,19 @@ function Track({ source, target, getTrackProps }) {
); );
} }
interface Props {
value: number;
onChange: (event: any) => void;
displayFunction: (value: number) => string;
}
/* Body */ /* Body */
// Two functions, essentially identical. // Two functions, essentially identical.
export function SliderElement({ onChange, value, displayFunction }) { export const SliderElement: React.FC<Props> = ({
onChange,
value,
displayFunction,
}) => {
return ( return (
<Slider <Slider
rootStyle={ rootStyle={
@ -122,4 +132,4 @@ export function SliderElement({ onChange, value, displayFunction }) {
</Tracks> </Tracks>
</Slider> </Slider>
); );
} };

View File

@ -1,86 +0,0 @@
import React, { useState } from "react";
let exampleInput = `{
"title": "Random example",
"description": "Just a random description of a random example",
"ids": [ "metaculus-372", "goodjudgmentopen-2244", "metaculus-7550", "kalshi-09d060ee-b184-4167-b86b-d773e56b4162", "wildeford-5d1a04e1a8", "metaculus-2817" ],
"creator": "Peter Parker"
}`;
export function DashboardCreator({ handleSubmit }) {
let [value, setValue] = useState(exampleInput);
const [displayingDoneMessage, setDisplayingDoneMessage] = useState(false);
const [displayingDoneMessageTimer, setDisplayingDoneMessageTimer] =
useState(null);
let handleChange = (event) => {
setValue(event.target.value);
};
let handleSubmitInner = (event) => {
clearTimeout(displayingDoneMessageTimer);
event.preventDefault();
//console.log(event)
console.log("value@handleSubmitInner@DashboardCreator");
//console.log(typeof(value));
console.log(value);
try {
let newData = JSON.parse(value);
//console.log(typeof(newData))
//console.log(newData)
if (!newData || !newData.ids || newData.ids.length == 0) {
throw Error("Not enough objects");
} else {
handleSubmit(newData);
setDisplayingDoneMessage(true);
let timer = setTimeout(() => setDisplayingDoneMessage(false), 3000);
setDisplayingDoneMessageTimer(timer);
}
} catch (error) {
setDisplayingDoneMessage(false);
//alert(error)
//console.log(error)
let substituteText = `Error: ${error.message}
Try something like:
${exampleInput}
Your old input was: ${value}`;
setValue(substituteText);
}
};
return (
<form onSubmit={handleSubmitInner} className="block place-centers">
<textarea
value={value}
onChange={handleChange}
rows={8}
cols={50}
className=""
/>
<br />
<div className="grid grid-cols-3 text-center">
<button
className="block col-start-2 col-end-2 w-full bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent rounded mt-5 p-10 text-center"
onClick={handleSubmitInner}
>
Create dashboard
</button>
<button
className={
displayingDoneMessage
? "block col-start-2 col-end-2 bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-2 border border-blue-500 hover:border-transparent rounded mt-2 p-2 text-center "
: "hidden "
}
>
Done!
</button>
<p className="block col-start-1 col-end-4 bg-gray-200 text-gray-700 py-2 px-4 border border-transparent mt-5 p-10 text-center mb-6">
You can find the necessary ids by toggling the advanced options in the
search, or by visiting{" "}
<a href="/api/all-forecasts">/api/all-forecasts</a>
</p>
</div>
</form>
);
}

View File

@ -1,778 +0,0 @@
/* Imports */
import React from "react";
import { FaRegClipboard } from "react-icons/fa";
import ReactMarkdown from "react-markdown";
import { FrontendForecast } from "../platforms";
/* Definitions */
/* Support functions */
// Short utils
let truncateTextSimple = (length, text) => {
text.length > length
? !!text.slice
? text.slice(0, length) + "..."
: ""
: text;
};
let truncateText = (length, text) => {
if (!text) {
return "";
}
if (!!text && text.length <= length) {
return text;
}
let breakpoints = " .!?";
let lastLetter = null;
let lastIndex = null;
for (let index = length; index > 0; index--) {
let letter = text[index];
if (breakpoints.includes(letter)) {
lastLetter = letter;
lastIndex = index;
break;
}
}
let truncatedText = !!text.slice
? text.slice(0, lastIndex) + (lastLetter != "." ? "..." : "..")
: "";
return truncatedText;
};
let formatProbability = (probability) => {
let percentage = probability * 100;
let percentageCapped =
percentage < 1
? "< 1%"
: percentage > 99
? "> 99%"
: percentage.toFixed(0) + "%";
return percentageCapped;
};
let formatNumber = (num) => {
if (Number(num) < 1000) {
return Number(num).toFixed(0);
} else if (num < 10000) {
return (Number(num) / 1000).toFixed(1) + "k";
} else {
return (Number(num) / 1000).toFixed(0) + "k";
}
};
let formatQualityIndicators = (qualityIndicators) => {
let newQualityIndicators = {};
for (let key in qualityIndicators) {
let newKey = formatQualityIndicator(key);
if (newKey) {
newQualityIndicators[newKey] = qualityIndicators[key];
}
}
return newQualityIndicators;
};
// replaceAll polyfill
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
}
function replaceAll(originalString, pattern, substitute) {
return originalString.replace(
new RegExp(escapeRegExp(pattern), "g"),
substitute
);
}
if (!String.prototype.replaceAll) {
String.prototype.replaceAll = function (pattern, substitute) {
let originalString = this;
// If a regex pattern
if (
Object.prototype.toString.call(pattern).toLowerCase() ===
"[object regexp]"
) {
return originalString.replace(pattern, substitute);
}
// If a string
return replaceAll(originalString, pattern, substitute);
};
}
let cleanText = (text) => {
// Note: should no longer be necessary
let textString = !!text ? text : "";
textString = textString
.replaceAll("] (", "](")
.replaceAll(") )", "))")
.replaceAll("( [", "([")
.replaceAll(") ,", "),")
.replaceAll("==", "") // Denotes a title in markdown
.replaceAll("Background\n", "")
.replaceAll("Context\n", "")
.replaceAll("--- \n", "- ")
.replaceAll(/\[(.*?)\]\(.*?\)/g, "$1");
textString = textString.slice(0, 1) == "=" ? textString.slice(1) : textString;
//console.log(textString)
return textString;
};
/* Faulty regex implementation
let cleanText = (text) => {
// Note: should no longer be necessary
let textString = !!text ? "" : text;
textString = textString
.replace(/\]\] \(/g, "](")
.replace(/\) \)/g, "))")
.replace(/\( \[/g, "([")
.replace(/\) ,/g, "),")
.replace(/==/g, "") // Denotes a title in markdown
.replace(/Background\n/g, "")
.replace(/Context\n/g, "")
.replace(/--- \n/g, "- ")
.replace(/\[(.*?)\]\(.*?\)/g, "$1");
textString = textString.slice(0, 1) == "=" && !!text.slice ? textString.slice(1) : textString;
//console.log(textString)
return textString;
};
*/
// Database-like functions
export function getstars(numstars) {
let stars = "★★☆☆☆";
switch (numstars) {
case 0:
stars = "☆☆☆☆☆";
break;
case 1:
stars = "★☆☆☆☆";
break;
case 2:
stars = "★★☆☆☆";
break;
case 3:
stars = "★★★☆☆";
break;
case 4:
stars = "★★★★☆";
break;
case 5:
stars = "★★★★★";
break;
default:
stars = "★★☆☆☆";
}
return stars;
}
function getStarsColor(numstars) {
let color = "text-yellow-400";
switch (numstars) {
case 0:
color = "text-red-400";
break;
case 1:
color = "text-red-400";
break;
case 2:
color = "text-orange-400";
break;
case 3:
color = "text-yellow-400";
break;
case 4:
color = "text-green-400";
break;
case 5:
color = "text-blue-400";
break;
default:
color = "text-yellow-400";
}
return color;
}
let primaryForecastColor = (probability) => {
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";
}
};
let textColor = (probability) => {
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";
}
};
let primaryEstimateAsText = (probability) => {
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";
}
};
let textColorFromScore = (score) => {
if (score < 0.4) {
return ["text-gray-900", "text-gray-900"];
} else {
return ["text-gray-400", "text-gray-400"];
}
};
let opacityFromScore = (score) => {
if (score < 0.4) {
return "opacity-100";
} else {
return "opacity-50";
}
};
let formatQualityIndicator = (indicator) => {
let result;
switch (indicator) {
case "numforecasts":
result = null;
break;
case "stars":
result = null;
break;
case "volume":
result = "Volume";
break;
case "numforecasters":
result = "Forecasters";
break;
case "yes_bid":
result = null; // "Yes bid"
break;
case "yes_ask":
result = null; // "Yes ask"
break;
case "spread":
result = "Spread";
break;
case "shares_volume":
result = "Shares vol.";
break;
case "open_interest":
result = "Interest";
break;
case "resolution_data":
result = null;
break;
case "liquidity":
result = "Liquidity";
break;
case "tradevolume":
result = "Volume";
break;
}
return result;
};
// Logical checks
let checkIfDisplayTimeStampAtBottom = (qualityIndicators) => {
let indicators = Object.keys(qualityIndicators);
if (indicators.length == 1 && indicators[0] == "stars") {
return true;
} else {
return false;
}
};
let getCurrencySymbolIfNeeded = ({
indicator,
platform,
}: {
indicator: any;
platform: string;
}) => {
let indicatorsWhichNeedCurrencySymbol = ["Volume", "Interest", "Liquidity"];
let dollarPlatforms = ["predictit", "kalshi", "polymarket"];
if (indicatorsWhichNeedCurrencySymbol.includes(indicator)) {
if (dollarPlatforms.includes(platform)) {
return "$";
} else {
return "£";
}
} else {
return "";
}
};
let getPercentageSymbolIfNeeded = ({ indicator, platform }) => {
let indicatorsWhichNeedPercentageSymbol = ["Spread"];
if (indicatorsWhichNeedPercentageSymbol.includes(indicator)) {
return "%";
} else {
return "";
}
};
/* Display functions*/
// Auxiliary
let displayMarkdown = (description) => {
let formatted = truncateText(250, cleanText(description)); //, description)//
// overflow-hidden overflow-ellipsis h-24
return formatted === "" ? (
""
) : (
<div className="overflow-clip">
<ReactMarkdown linkTarget="_blank" className="font-normal">
{formatted}
</ReactMarkdown>
</div>
);
};
let generateOptionRow = (option) => {
let chooseColor = (probability) => {
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="items-center flex">
<div
className={`${chooseColor(
option.probability
)} w-14 flex-none rounded-md py-0.5 my-1 text-sm text-center`}
>
{formatProbability(option.probability)}
</div>
<div className="flex-auto text-gray-700 pl-3 leading-snug text-sm">
{option.name}
</div>
</div>
);
};
let formatForecastOptions = (options) => {
let optionsSorted = options.sort((a, b) => b.probability - a.probability);
let optionsMax5 = !!optionsSorted.slice ? optionsSorted.slice(0, 5) : []; // display max 5 options.
let result = optionsMax5.map((option) => generateOptionRow(option));
return result;
};
let showFirstQualityIndicator = ({
numforecasts,
timestamp,
showTimeStamp,
qualityindicators,
}) => {
if (!!numforecasts) {
return (
<div className="flex col-span-1 row-span-1">
{/*<span>{` ${numforecasts == 1 ? "Forecast" : "Forecasts:"}`}</span>&nbsp;*/}
<span>{"Forecasts:"}</span>&nbsp;
<span className="font-bold">{Number(numforecasts).toFixed(0)}</span>
</div>
);
} else if (
showTimeStamp &&
checkIfDisplayTimeStampAtBottom(qualityindicators)
) {
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: ${
timestamp && !!timestamp.slice ? timestamp.slice(0, 10) : "unknown"
}`}
</span>
);
} else {
return null;
}
};
const displayQualityIndicators: React.FC<{
numforecasts: number;
timestamp: number;
showTimeStamp: boolean;
qualityindicators: any;
platform: string; // id string - e.g. "goodjudgment", not "Good Judgment"
}> = ({
numforecasts,
timestamp,
showTimeStamp,
qualityindicators,
platform,
}) => {
// grid grid-cols-1
return (
<div className="text-sm">
{showFirstQualityIndicator({
numforecasts,
timestamp,
showTimeStamp,
qualityindicators,
})}
{Object.entries(formatQualityIndicators(qualityindicators)).map(
(entry, i) => {
return (
<div className="col-span-1 row-span-1">
<span>{`${entry[0]}:`}</span>&nbsp;
<span className="font-bold">
{`${getCurrencySymbolIfNeeded({
indicator: entry[0],
platform,
})}${formatNumber(entry[1])}${getPercentageSymbolIfNeeded({
indicator: entry[0],
platform,
})}`}
</span>
</div>
);
}
)}
</div>
);
};
// Main display functions
let forecastFooter = ({
stars,
platform,
platformLabel,
numforecasts,
qualityindicators,
timestamp,
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
className={`grid grid-cols-3 ${
expandFooterToFullWidth ? "justify-between" : ""
} text-gray-500 mb-2 mt-1`}
>
<div
className={`self-center col-span-1 ${getStarsColor(stars)} ${
debuggingWithBackground ? "bg-red-200" : ""
}`}
>
{getstars(stars)}
</div>
<div
className={`${
expandFooterToFullWidth ? "place-self-center" : "self-center"
} col-span-1 font-bold ${debuggingWithBackground ? "bg-red-100" : ""}`}
>
{platformLabel
.replace("Good Judgment Open", "GJOpen")
.replace(/ /g, "\u00a0")}
</div>
<div
className={`${
expandFooterToFullWidth
? "justify-self-end mr-4"
: "justify-self-center"
} col-span-1 ${debuggingWithBackground ? "bg-red-100" : ""}`}
>
{displayQualityIndicators({
numforecasts,
timestamp,
showTimeStamp,
qualityindicators,
platform,
})}
</div>
</div>
);
};
/* Body */
interface SingleProps {
forecast: FrontendForecast;
showTimeStamp: boolean;
expandFooterToFullWidth: boolean;
showIdToggle?: boolean;
}
export const DisplayForecast: React.FC<SingleProps> = ({
forecast: {
id,
title,
url,
platform,
platformLabel,
description,
options,
qualityindicators,
timestamp,
visualization,
},
showTimeStamp,
expandFooterToFullWidth,
showIdToggle,
}) => {
// const [isJustCopiedSignalVisible, setIsJustCopiedSignalVisible] = useState(false)
const isJustCopiedSignalVisible = false;
return (
<a
key={`displayForecast-${id}`}
href={url}
className="hover:bg-gray-100 hover:no-underline cursor-pointer flex flex-col px-4 py-3 bg-white rounded-md shadow place-content-stretch flex-grow no-underline"
target="_blank"
>
<div className="flex-grow">
<div
className={`text-gray-800 ${opacityFromScore(
0
)} text-lg mb-2 font-medium justify-self-start`}
>
<div
className={`${
showIdToggle ? "" : "hidden"
} flex items-center justify-center mt-2 mb-3 text-sm bg-transparent py-4 px-4 border rounded mt-5 p-10 text-center mb-2 mr-10 ml-10 hover:bg-blue-300 text-blue-400 text-blue-700 ${
isJustCopiedSignalVisible
? " hover:text-white hover:border-blue-700 border-blue-400 hover:bg-blue-700"
: "border-blue-400 hover:text-white hover:border-transparent"
}`}
id="toggle"
onClick={(e) => {
e.preventDefault();
navigator.clipboard.writeText(`"${id}"`);
// setIsJustCopiedSignalVisible(true);
// setTimeout(() => setIsJustCopiedSignalVisible(false), 1000);
// This is just personal preference.
// I prefer to not show the whole text area selected.
}}
>
<span className={``}>{`[${id}]`}</span>
<FaRegClipboard className={`ml-3`} />
</div>
{title.replace("</a>", "")}
</div>
{options.length == 2 &&
(options[0].name == "Yes" || options[0].name == "No") && (
<div>
<div className="grid mb-5 mt-4 mb-5 grid-cols-1 sm:grid-rows-1">
<div className="flex-grow col-span-1 w-full items-center justify-center">
<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
)} ml-2 text-gray-500 inline-block`}
>
{primaryEstimateAsText(options[0].probability)}
</span>
</div>
<div
className={`hidden sm:${
showTimeStamp &&
!checkIfDisplayTimeStampAtBottom(qualityindicators)
? "flex"
: "hidden"
} ${opacityFromScore(
0
)} row-end-2 col-start-2 col-end-2 row-start-1 row-end-1 col-span-1 items-center justify-center text-gray-600 ml-3 mr-2 `}
>
<svg className="mt-1" height="10" width="16">
<circle cx="4" cy="4" r="4" fill="rgb(29, 78, 216)" />
</svg>
{`Last updated: ${
timestamp ? timestamp.slice(0, 10) : "unknown"
}`}
</div>
</div>
</div>
)}
{(options.length != 2 ||
(options[0].name != "Yes" && options[0].name != "No")) && (
<>
<div className={`mb-2 mt-2 ${opacityFromScore(0)}`}>
{formatForecastOptions(options)}
</div>
<div
className={`hidden sm:${
showTimeStamp &&
!checkIfDisplayTimeStampAtBottom(qualityindicators)
? "flex"
: "hidden"
} ${opacityFromScore(
0
)} col-start-2 col-end-2 row-start-1 row-end-1 text-gray-600 mt-3 mb-3`}
>
<svg className="ml-6 mr-1 mt-2" height="10" width="16">
<circle cx="4" cy="4" r="4" fill="rgb(29, 78, 216)" />
</svg>
{`Last updated: ${
timestamp ? timestamp.slice(0, 10) : "unknown"
}`}
</div>
</>
)}
{platform !== "guesstimate" && options.length < 3 && (
<div className={`text-gray-500 ${opacityFromScore(0)} mt-4`}>
{displayMarkdown(description)}
</div>
)}
{platform === "guesstimate" && (
<img
className="rounded-sm mb-1"
src={visualization}
alt="Guesstimate Screenshot"
/>
)}
</div>
<div
className={`flex sm:hidden ${
!showTimeStamp ? "hidden" : ""
} items-center justify-center mt-2 mb-4 text-gray-600`}
>
{/* This one is exclusively for mobile*/}
<svg className="" height="10" width="16">
<circle cx="4" cy="4" r="4" fill="rgb(29, 78, 216)" />
</svg>
{`Last updated: ${timestamp ? timestamp.slice(0, 10) : "unknown"}`}
</div>
<div className={`${opacityFromScore(0)} w-full`}>
{forecastFooter({
stars: qualityindicators.stars,
platform: platform,
platformLabel: platformLabel || platform, // author || platformLabel,
numforecasts: qualityindicators.numforecasts,
qualityindicators,
timestamp,
showTimeStamp,
expandFooterToFullWidth,
})}
</div>
</a>
);
};
interface Props {
results: FrontendForecast[];
numDisplay: number;
showIdToggle: boolean;
}
const DisplayForecasts: React.FC<Props> = ({
results,
numDisplay,
showIdToggle,
}) => {
if (!results) {
return <></>;
}
return (
<>
{results.slice(0, numDisplay).map((result) => (
/*let displayWithMetaculusCapture =
fuseSearchResult.item.platform == "Metaculus"
? metaculusEmbed(fuseSearchResult.item)
: displayForecast({ ...fuseSearchResult.item });
*/
<DisplayForecast
forecast={result}
showTimeStamp={false}
expandFooterToFullWidth={false}
showIdToggle={showIdToggle}
/>
))}
</>
);
};
export default DisplayForecasts;

View File

@ -1,5 +1,5 @@
import displayForecasts from "./displayForecasts"; import { DisplayForecasts } from "./DisplayForecasts";
import displayOneForecast from "./displayOneForecastForCapture"; import { DisplayOneForecastForCapture } from "./DisplayOneForecastForCapture";
export function displayForecastsWrapperForSearch({ export function displayForecastsWrapperForSearch({
results, results,
@ -8,7 +8,11 @@ export function displayForecastsWrapperForSearch({
}) { }) {
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{displayForecasts({ results: results || [], numDisplay, showIdToggle })} <DisplayForecasts
results={results || []}
numDisplay={numDisplay}
showIdToggle={showIdToggle}
/>
</div> </div>
); );
} }
@ -19,9 +23,9 @@ export function displayForecastsWrapperForCapture({
}) { }) {
return ( return (
<div className="grid grid-cols-1 w-full justify-center"> <div className="grid grid-cols-1 w-full justify-center">
{displayOneForecast({ <DisplayOneForecastForCapture
result: results[whichResultToDisplayAndCapture], result={results[whichResultToDisplayAndCapture]}
})} />
</div> </div>
); );
} }

15
src/web/hooks.ts Normal file
View File

@ -0,0 +1,15 @@
import React, { DependencyList, EffectCallback, useEffect } from "react";
export const useNoInitialEffect = (
effect: EffectCallback,
deps: DependencyList
) => {
const initial = React.useRef(true);
useEffect(() => {
if (initial.current) {
initial.current = false;
return;
}
return effect();
}, deps);
};

View File

@ -1,25 +1,15 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React, { DependencyList, EffectCallback, Fragment, useEffect, useState } from "react"; import React, { Fragment, useState } from "react";
import ButtonsForStars from "../display/buttonsForStars"; import { ButtonsForStars } from "../display/ButtonsForStars";
import Form from "../display/form"; import { MultiSelectPlatform } from "../display/MultiSelectPlatform";
import { MultiSelectPlatform } from "../display/multiSelectPlatforms"; import { QueryForm } from "../display/QueryForm";
import { SliderElement } from "../display/slider"; import { SliderElement } from "../display/SliderElement";
import { useNoInitialEffect } from "../hooks";
import { FrontendForecast } from "../platforms"; import { FrontendForecast } from "../platforms";
import searchAccordingToQueryData from "../worker/searchAccordingToQueryData"; import searchAccordingToQueryData from "../worker/searchAccordingToQueryData";
import { Props as AnySearchPageProps, QueryParameters } from "./anySearchPage"; import { Props as AnySearchPageProps, QueryParameters } from "./anySearchPage";
const useNoInitialEffect = (effect: EffectCallback, deps: DependencyList) => {
const initial = React.useRef(true);
useEffect(() => {
if (initial.current) {
initial.current = false;
return;
}
return effect();
}, deps);
};
interface Props extends AnySearchPageProps { interface Props extends AnySearchPageProps {
hasSearchbar: boolean; hasSearchbar: boolean;
hasCapture: boolean; hasCapture: boolean;
@ -76,7 +66,7 @@ const CommonDisplay: React.FC<Props> = ({
numDisplay, numDisplay,
}; };
let filterManually = ( const filterManually = (
queryData: QueryParameters, queryData: QueryParameters,
results: FrontendForecast[] results: FrontendForecast[]
) => { ) => {
@ -102,7 +92,7 @@ const CommonDisplay: React.FC<Props> = ({
const queryIsEmpty = const queryIsEmpty =
!queryData || queryData.query == "" || queryData.query == undefined; !queryData || queryData.query == "" || queryData.query == undefined;
let results = queryIsEmpty const results = queryIsEmpty
? filterManually(queryData, defaultResults) ? filterManually(queryData, defaultResults)
: await searchAccordingToQueryData(queryData, numDisplay); : await searchAccordingToQueryData(queryData, numDisplay);
@ -110,8 +100,8 @@ const CommonDisplay: React.FC<Props> = ({
} }
// I don't want the function which display forecasts (displayForecasts) to change with a change in queryParameters. But I want it to have access to the queryParameters, and in particular access to queryParameters.numDisplay. Hence why this function lives inside Home. // I don't want the function which display forecasts (displayForecasts) to change with a change in queryParameters. But I want it to have access to the queryParameters, and in particular access to queryParameters.numDisplay. Hence why this function lives inside Home.
let getInfoToDisplayForecastsFunction = () => { const getInfoToDisplayForecastsFunction = () => {
let numDisplayRounded = const numDisplayRounded =
numDisplay % 3 != 0 numDisplay % 3 != 0
? numDisplay + (3 - (Math.round(numDisplay) % 3)) ? numDisplay + (3 - (Math.round(numDisplay) % 3))
: numDisplay; : numDisplay;
@ -156,7 +146,7 @@ const CommonDisplay: React.FC<Props> = ({
useNoInitialEffect(() => { useNoInitialEffect(() => {
setResults([]); setResults([]);
let newTimeoutId = setTimeout(() => { const newTimeoutId = setTimeout(() => {
updateRoute(); updateRoute();
executeSearchOrAnswerWithDefaultResults(); executeSearchOrAnswerWithDefaultResults();
}, 500); }, 500);
@ -170,7 +160,7 @@ const CommonDisplay: React.FC<Props> = ({
/* State controllers */ /* State controllers */
/* Change the stars threshold */ /* Change the stars threshold */
let onChangeStars = (value: number) => { const onChangeStars = (value: number) => {
setQueryParameters({ setQueryParameters({
...queryParameters, ...queryParameters,
starsThreshold: value, starsThreshold: value,
@ -178,7 +168,7 @@ const CommonDisplay: React.FC<Props> = ({
}; };
/* Change the number of elements to display */ /* Change the number of elements to display */
let displayFunctionNumDisplaySlider = (value) => { const displayFunctionNumDisplaySlider = (value: number) => {
return ( return (
"Show " + "Show " +
Math.round(value) + Math.round(value) +
@ -186,16 +176,16 @@ const CommonDisplay: React.FC<Props> = ({
(Math.round(value) === 1 ? "" : "s") (Math.round(value) === 1 ? "" : "s")
); );
}; };
let onChangeSliderForNumDisplay = (event) => { const onChangeSliderForNumDisplay = (event) => {
setNumDisplay(Math.round(event[0])); setNumDisplay(Math.round(event[0]));
setForceSearch(forceSearch + 1); // FIXME - force new search iff numDisplay is greater than last search limit setForceSearch(forceSearch + 1); // FIXME - force new search iff numDisplay is greater than last search limit
}; };
/* Change the forecast threshold */ /* Change the forecast threshold */
let displayFunctionNumForecasts = (value: number) => { const displayFunctionNumForecasts = (value: number) => {
return "# Forecasts > " + Math.round(value); return "# Forecasts > " + Math.round(value);
}; };
let onChangeSliderForNumForecasts = (event) => { const onChangeSliderForNumForecasts = (event) => {
setQueryParameters({ setQueryParameters({
...queryParameters, ...queryParameters,
forecastsThreshold: Math.round(event[0]), forecastsThreshold: Math.round(event[0]),
@ -203,7 +193,7 @@ const CommonDisplay: React.FC<Props> = ({
}; };
/* Change on the search bar */ /* Change on the search bar */
let onChangeSearchBar = (value: string) => { const onChangeSearchBar = (value: string) => {
setQueryParameters({ setQueryParameters({
...queryParameters, ...queryParameters,
query: value, query: value,
@ -211,7 +201,7 @@ const CommonDisplay: React.FC<Props> = ({
}; };
/* Change selected platforms */ /* Change selected platforms */
let onChangeSelectedPlatforms = (value) => { const onChangeSelectedPlatforms = (value) => {
setQueryParameters({ setQueryParameters({
...queryParameters, ...queryParameters,
forecastingPlatforms: value, forecastingPlatforms: value,
@ -219,18 +209,18 @@ const CommonDisplay: React.FC<Props> = ({
}; };
// Change show id // Change show id
let onChangeShowId = () => { const onChangeShowId = () => {
setShowIdToggle(!showIdToggle); setShowIdToggle(!showIdToggle);
}; };
// Capture functionality // Capture functionality
let onClickBack = () => { const onClickBack = () => {
let decreaseUntil0 = (num: number) => (num - 1 > 0 ? num - 1 : 0); const decreaseUntil0 = (num: number) => (num - 1 > 0 ? num - 1 : 0);
setWhichResultToDisplayAndCapture( setWhichResultToDisplayAndCapture(
decreaseUntil0(whichResultToDisplayAndCapture) decreaseUntil0(whichResultToDisplayAndCapture)
); );
}; };
let onClickForward = (whichResultToDisplayAndCapture: number) => { const onClickForward = (whichResultToDisplayAndCapture: number) => {
setWhichResultToDisplayAndCapture(whichResultToDisplayAndCapture + 1); setWhichResultToDisplayAndCapture(whichResultToDisplayAndCapture + 1);
}; };
@ -240,7 +230,7 @@ const CommonDisplay: React.FC<Props> = ({
<label className="mb-4 mt-4 flex flex-row justify-center items-center"> <label className="mb-4 mt-4 flex flex-row justify-center items-center">
{hasSearchbar ? ( {hasSearchbar ? (
<div className="w-10/12 mb-2"> <div className="w-10/12 mb-2">
<Form <QueryForm
value={queryParameters.query} value={queryParameters.query}
onChange={onChangeSearchBar} onChange={onChangeSearchBar}
placeholder={placeholder} placeholder={placeholder}

View File

@ -58,10 +58,6 @@ export const getServerSideProps: GetServerSideProps<Props> = async (
).split("|"); ).split("|");
} }
const platformNameToLabel = Object.fromEntries(
platforms.map((platform) => [platform.name, platform.label])
);
const defaultNumDisplay = 21; const defaultNumDisplay = 21;
const initialNumDisplay = Number(urlQuery.numDisplay) || defaultNumDisplay; const initialNumDisplay = Number(urlQuery.numDisplay) || defaultNumDisplay;

View File

@ -1,19 +1,20 @@
import axios from "axios"; import axios from "axios";
import { DashboardItem } from "../../backend/dashboards"; import { DashboardItem } from "../../backend/dashboards";
import { Forecast } from "../../backend/platforms"; import { Forecast, getPlatformsConfig } from "../../backend/platforms";
import { addLabelsToForecasts, FrontendForecast } from "../platforms";
export async function getDashboardForecastsByDashboardId({ export async function getDashboardForecastsByDashboardId({
dashboardId, dashboardId,
}): Promise<{ }): Promise<{
dashboardForecasts: Forecast[]; dashboardForecasts: FrontendForecast[];
dashboardItem: DashboardItem; dashboardItem: DashboardItem;
}> { }> {
console.log("getDashboardForecastsByDashboardId: "); console.log("getDashboardForecastsByDashboardId: ");
let dashboardContents: Forecast[] = []; let dashboardForecasts: Forecast[] = [];
let dashboardItem: DashboardItem | any = null; let dashboardItem: DashboardItem | null = null;
try { try {
let { data } = await axios({ const { data } = await axios({
url: `${process.env.NEXT_PUBLIC_SITE_URL}/api/dashboard-by-id`, url: `${process.env.NEXT_PUBLIC_SITE_URL}/api/dashboard-by-id`,
method: "post", method: "post",
data: { data: {
@ -21,13 +22,19 @@ export async function getDashboardForecastsByDashboardId({
}, },
}); });
console.log(data); console.log(data);
dashboardContents = data.dashboardContents;
dashboardForecasts = data.dashboardContents;
dashboardItem = data.dashboardItem as DashboardItem; dashboardItem = data.dashboardItem as DashboardItem;
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} finally { } finally {
const labeledDashboardForecasts = addLabelsToForecasts(
dashboardForecasts,
getPlatformsConfig({ withGuesstimate: false })
);
return { return {
dashboardForecasts: dashboardContents, dashboardForecasts: labeledDashboardForecasts,
dashboardItem, dashboardItem,
}; };
} }