From de2a581ee2d211ba70b27fff839fc872ede9c1b9 Mon Sep 17 00:00:00 2001 From: Harman-singh-waraich Date: Thu, 17 Sep 2026 19:58:16 +0530 Subject: [PATCH 001/621] feat(web): estimate-slider-functionality --- .../[parentMarket]/_components/MarketPage.tsx | 44 +++- .../[parentMarket]/_components/OptionCard.tsx | 102 ++++++--- .../_components/ParticipateSection.tsx | 19 +- .../_components/__tests__/MarketPage.test.tsx | 8 + .../_components/__tests__/OptionCard.test.tsx | 195 ++++++++++++++++-- .../ui/Dialog.tsx} | 26 +-- .../components/ui/__tests__/Dialog.test.tsx | 40 ++++ .../predict/components/EstimateActions.tsx | 47 +++++ .../predict/components/EstimateCsvDialog.tsx | 115 +++++++++++ .../predict/components/EstimateSlider.tsx | 120 +++++++++++ .../__tests__/EstimateActions.test.tsx | 75 +++++++ .../__tests__/EstimateCsvDialog.test.tsx | 95 +++++++++ .../__tests__/useEstimateHydration.test.tsx | 67 ++++++ .../predict/hooks/useEstimateHydration.ts | 20 ++ .../stores/__tests__/estimateStore.test.ts | 101 +++++++++ .../features/predict/stores/estimateStore.ts | 104 ++++++++++ apps/web/src/features/predict/types.ts | 9 + .../utils/__tests__/estimateCsv.test.ts | 137 ++++++++++++ .../predict/utils/__tests__/estimates.test.ts | 94 +++++++++ .../src/features/predict/utils/estimateCsv.ts | 102 +++++++++ .../src/features/predict/utils/estimates.ts | 62 ++++++ apps/web/src/features/sessions/types.ts | 17 +- .../utils/__tests__/latestEstimates.test.ts | 39 ++++ .../sessions/utils/latestEstimates.ts | 14 ++ .../components/TradeWallet/AmountDialog.tsx | 7 +- .../components/TradeWallet/TransferDialog.tsx | 7 +- apps/web/src/types/optionScale.ts | 13 ++ apps/web/src/utils/__tests__/date.test.ts | 8 +- apps/web/src/utils/date.ts | 7 + apps/web/src/utils/download.ts | 10 + 30 files changed, 1612 insertions(+), 92 deletions(-) rename apps/web/src/{features/trade-executor/components/TradeWallet/WalletDialog.tsx => components/ui/Dialog.tsx} (71%) create mode 100644 apps/web/src/components/ui/__tests__/Dialog.test.tsx create mode 100644 apps/web/src/features/predict/components/EstimateActions.tsx create mode 100644 apps/web/src/features/predict/components/EstimateCsvDialog.tsx create mode 100644 apps/web/src/features/predict/components/EstimateSlider.tsx create mode 100644 apps/web/src/features/predict/components/__tests__/EstimateActions.test.tsx create mode 100644 apps/web/src/features/predict/components/__tests__/EstimateCsvDialog.test.tsx create mode 100644 apps/web/src/features/predict/hooks/__tests__/useEstimateHydration.test.tsx create mode 100644 apps/web/src/features/predict/hooks/useEstimateHydration.ts create mode 100644 apps/web/src/features/predict/stores/__tests__/estimateStore.test.ts create mode 100644 apps/web/src/features/predict/stores/estimateStore.ts create mode 100644 apps/web/src/features/predict/types.ts create mode 100644 apps/web/src/features/predict/utils/__tests__/estimateCsv.test.ts create mode 100644 apps/web/src/features/predict/utils/__tests__/estimates.test.ts create mode 100644 apps/web/src/features/predict/utils/estimateCsv.ts create mode 100644 apps/web/src/features/predict/utils/estimates.ts create mode 100644 apps/web/src/features/sessions/utils/__tests__/latestEstimates.test.ts create mode 100644 apps/web/src/features/sessions/utils/latestEstimates.ts create mode 100644 apps/web/src/types/optionScale.ts create mode 100644 apps/web/src/utils/download.ts diff --git a/apps/web/src/app/market/[parentMarket]/_components/MarketPage.tsx b/apps/web/src/app/market/[parentMarket]/_components/MarketPage.tsx index 9d870548..311d2535 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/MarketPage.tsx +++ b/apps/web/src/app/market/[parentMarket]/_components/MarketPage.tsx @@ -6,9 +6,13 @@ import type { ReactNode } from "react"; import { isAddress } from "viem"; +import { EstimateActions } from "@/features/predict/components/EstimateActions"; +import { useEstimateHydration } from "@/features/predict/hooks/useEstimateHydration"; +import { snapEstimate } from "@/features/predict/utils/estimates"; import { useSession } from "@/features/sessions/api/getSession"; import { useSessionChart } from "@/features/sessions/api/getSessionChart"; import { useSessionStats } from "@/features/sessions/api/getSessionStats"; +import { latestEstimates } from "@/features/sessions/utils/latestEstimates"; import { optionNoun } from "@/features/sessions/utils/optionNoun"; import { sessionStatus } from "@/features/sessions/utils/toSessionDetail"; @@ -29,6 +33,7 @@ export function MarketPage({ parentMarket }: { parentMarket: string }) { const session = useSession(parentMarket); const stats = useSessionStats(session.data?.id); const chart = useSessionChart(session.data ?? undefined); + useEstimateHydration(); if (!isAddress(parentMarket, { strict: false }) || session.data === null) { return ( @@ -54,30 +59,51 @@ export function MarketPage({ parentMarket }: { parentMarket: string }) { } const detail = session.data; + const status = sessionStatus(detail, new Date()); + const noun = optionNoun(detail.itemNamePlural); + // the pool's number is an unbounded float; held on each option's grid so "at the market" is exact + const latest = chart.data ? latestEstimates(chart.data) : {}; + const marketEstimates = Object.fromEntries( + detail.options.flatMap((option) => { + const raw = latest[option.market.toLowerCase()]; + return raw === undefined ? [] : [[option.market.toLowerCase(), snapEstimate(raw, option)]]; + }), + ) as Record; return ( - + {chart.isPending ? ( + ) : chart.isError ? ( +

Could not load the price history. Reload in a moment.

) : chart.data !== undefined ? ( ) : null}
- + {detail.options.map((option) => ( - + ))} +
diff --git a/apps/web/src/app/market/[parentMarket]/_components/OptionCard.tsx b/apps/web/src/app/market/[parentMarket]/_components/OptionCard.tsx index 25d17499..d1124641 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/OptionCard.tsx +++ b/apps/web/src/app/market/[parentMarket]/_components/OptionCard.tsx @@ -1,14 +1,47 @@ -import { Accordion, CustomAccordion, Slider } from "@kleros/ui-components-library"; +import { Accordion, CustomAccordion } from "@kleros/ui-components-library"; import clsx from "clsx"; +import { EstimateSlider } from "@/features/predict/components/EstimateSlider"; +import { useEstimate, useEstimateStore } from "@/features/predict/stores/estimateStore"; +import { estimateDirection } from "@/features/predict/utils/estimates"; import { DisplayBlocks } from "@/features/sessions/components/DisplayBlocks"; -import type { SessionOption } from "@/features/sessions/types"; +import type { SessionOption, SessionStatus } from "@/features/sessions/types"; +import CheckCircle from "@/assets/svg/check-circle.svg"; import MinusOutline from "@/assets/svg/minus-outline.svg"; -import { formatEstimate } from "@/utils/formatEstimate"; +interface OptionCardProps { + option: SessionOption; + parentMarket: string; + status: SessionStatus; + /** Where the market sits now; undefined where the option has no trading. */ + marketEstimate: number | undefined; + isMarketPending: boolean; + /** True when the market numbers could not be fetched at all, as opposed to this option having none. */ + isMarketUnavailable?: boolean; +} + +// REVISIT WHEN: the predict flow lands. A closed session should read "Trading has closed for this +// session." here; it is left open for now so past-date sessions on the fork can be worked with. +const LOCKED: Partial> = { + "setting-up": "Estimates open once every option is set up.", +}; + +export function OptionCard({ + option, + parentMarket, + status, + marketEstimate, + isMarketPending, + isMarketUnavailable = false, +}: OptionCardProps) { + const value = useEstimate(parentMarket, option.market); + const setEstimate = useEstimateStore((state) => state.setEstimate); + const clearEstimate = useEstimateStore((state) => state.clearEstimate); + + const isSelected = + value !== undefined && marketEstimate !== undefined && estimateDirection(value, marketEstimate) !== null; -export function OptionCard({ option }: { option: SessionOption }) { return ( div]:my-0", )} - items={[{ title: , body: }]} + items={[ + { + title: , + body: ( + + {option.upperBound > option.lowerBound ? ( + setEstimate(parentMarket, option.market, next)} + onClear={() => clearEstimate(parentMarket, option.market)} + lockedReason={ + LOCKED[status] ?? + (isMarketUnavailable ? "Market estimates could not be loaded. Reload in a moment." : undefined) + } + /> + ) : ( +

The scale for this option could not be read.

+ )} + + ), + }, + ]} /> ); } -function TitleRow({ option }: { option: SessionOption }) { +function TitleRow({ option, isSelected }: { option: SessionOption; isSelected: boolean }) { return (
@@ -30,38 +87,19 @@ function TitleRow({ option }: { option: SessionOption }) {

{option.name}

- + {isSelected ? ( + + ) : ( + + )} ); } -function Body({ option }: { option: SessionOption }) { - const hasScale = option.upperBound > option.lowerBound; - +function Body({ option, children }: { option: SessionOption; children: React.ReactNode }) { return (
-
- {hasScale ? ( - <> - formatEstimate(value, option.unit)} - callback={() => {}} - isDisabled - /> -

Setting estimates is not available yet.

- - ) : ( -

The scale for this option could not be read.

- )} -
+
{children}
; + canEstimate: boolean; +} + +export function ParticipateSection({ session, marketEstimates, canEstimate }: ParticipateSectionProps) { const noun = optionNoun(session.itemNamePlural); const market = toWalletMarket(session); @@ -27,7 +32,13 @@ export function ParticipateSection({ session }: { session: SessionDetail }) {
-
); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/MarketPage.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/MarketPage.test.tsx index d8317db4..0dcbdf5c 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/__tests__/MarketPage.test.tsx +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/MarketPage.test.tsx @@ -93,6 +93,14 @@ describe("MarketPage", () => { /* The chart itself is canvas: rendered states would need the real component under jsdom or a stub asserting its own scaffolding, so they are verified live instead. */ + it("says the price history failed instead of drawing nothing", async () => { + answerQueries(Promise.reject(new Error("boom"))); + + renderPage(); + + expect(await screen.findByText("Could not load the price history. Reload in a moment.")).toBeInTheDocument(); + }); + it("holds the chart's place while the price history loads", async () => { answerQueries(new Promise(() => {})); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/OptionCard.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/OptionCard.test.tsx index e6410dfa..1a0aa05f 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/__tests__/OptionCard.test.tsx +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/OptionCard.test.tsx @@ -1,8 +1,10 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; -import type { SessionOption } from "@/features/sessions/types"; +import { useEstimateStore } from "@/features/predict/stores/estimateStore"; +import { estimateStep } from "@/features/predict/utils/estimates"; +import type { SessionOption, SessionStatus } from "@/features/sessions/types"; import { addresses } from "@/testing/fixtures"; @@ -23,44 +25,201 @@ const option: SessionOption = { blocks: [{ type: "markdown", body: "Directed *Barbie*." }], }; +const PARENT = addresses.parentMarket; +const MARKET_ESTIMATE = 312; + +function renderCard(overrides: { status?: SessionStatus; marketEstimate?: number; isMarketPending?: boolean } = {}) { + return render( + , + ); +} + async function open() { await userEvent.click(screen.getByText("Greta Gerwig")); } +const saved = () => useEstimateStore.getState().estimates[PARENT]?.[option.market]; + +beforeEach(() => { + useEstimateStore.setState({ estimates: {} }); +}); + describe("OptionCard", () => { - it("shows the option's name while collapsed", () => { - render(); + it("shows the option's name and no tick while nothing is set", () => { + renderCard(); expect(screen.getByText("Greta Gerwig")).toBeInTheDocument(); + expect(screen.queryByRole("img", { name: "Estimate set" })).toBeNull(); }); - it("opens to an estimate scale bounded in the option's own unit", async () => { - render(); + it("opens to an estimate scale bounded in the option's own unit, with the market marked", async () => { + renderCard(); await open(); expect(screen.getByText("0 $M")).toBeInTheDocument(); expect(screen.getByText("500 $M")).toBeInTheDocument(); + expect(screen.getByText("Market 312 $M")).toBeInTheDocument(); + }); + + it("starts the thumb at the market estimate", async () => { + renderCard(); + + await open(); + + expect(screen.getByRole("slider")).toHaveValue(String(MARKET_ESTIMATE)); + expect(screen.getByText("At the market estimate. Move it to make a prediction.")).toBeInTheDocument(); + }); + + it("saves a typed estimate and ticks the option", async () => { + renderCard(); + await open(); + + const field = screen.getByRole("textbox", { name: /Your estimate/ }); + await userEvent.clear(field); + await userEvent.type(field, "250"); + await userEvent.tab(); + + expect(saved()).toBe(250); + expect(screen.getByText("Lower than the market estimate")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Estimate set" })).toBeInTheDocument(); + }); + + it("saves a slider step made with the keyboard", async () => { + renderCard(); + await open(); + + screen.getByRole("slider").focus(); + await userEvent.keyboard("{ArrowRight}"); + + expect(saved()).toBe(MARKET_ESTIMATE + estimateStep(option)); + expect(screen.getByRole("img", { name: "Estimate set" })).toBeInTheDocument(); + }); + + it("keeps a typed estimate inside the option's scale, which the field itself enforces", async () => { + renderCard(); + await open(); + + const field = screen.getByRole("textbox", { name: /Your estimate/ }); + await userEvent.clear(field); + await userEvent.type(field, "900"); + await userEvent.tab(); + + expect(saved()).toBe(500); + }); + + it("forgets an estimate typed back to the market's own number", async () => { + useEstimateStore.getState().setEstimate(PARENT, option.market, 250); + renderCard(); + await open(); + + const field = screen.getByRole("textbox", { name: /Your estimate/ }); + await userEvent.clear(field); + await userEvent.type(field, String(MARKET_ESTIMATE)); + await userEvent.tab(); + + expect(saved()).toBeUndefined(); + expect(screen.queryByRole("img", { name: "Estimate set" })).toBeNull(); + }); + + it("shows a saved estimate as the thumb and as higher than the market", async () => { + useEstimateStore.getState().setEstimate(PARENT, option.market, 400); + renderCard(); + + await open(); + + expect(screen.getByRole("slider")).toHaveValue("400"); + expect(screen.getByText("Higher than the market estimate")).toBeInTheDocument(); }); - it("says plainly that estimates cannot be set yet", async () => { - render(); + it("refuses estimates where the option has no trading", async () => { + renderCard({ marketEstimate: undefined }); await open(); - expect(screen.getByText("Setting estimates is not available yet.")).toBeInTheDocument(); + expect(screen.getByRole("slider")).toBeDisabled(); + expect(screen.getByText("No trading is available for this option yet.")).toBeInTheDocument(); }); - it("keeps the slider disabled", async () => { - render(); + it("refuses estimates while the session is still being set up", async () => { + renderCard({ status: "setting-up" }); await open(); expect(screen.getByRole("slider")).toBeDisabled(); + expect(screen.getByText("Estimates open once every option is set up.")).toBeInTheDocument(); + }); + + // REVISIT WHEN: the predict flow lands; a closed session should lock the slider again. + it("still takes estimates after trading has closed, for now", async () => { + renderCard({ status: "closed" }); + + await open(); + + expect(screen.getByRole("slider")).toBeEnabled(); + }); + + it("says the market numbers could not be loaded, rather than that there is no trading", async () => { + render( + , + ); + + await open(); + + expect(screen.getByRole("slider")).toBeDisabled(); + expect(screen.getByText("Market estimates could not be loaded. Reload in a moment.")).toBeInTheDocument(); + expect(screen.queryByText("No trading is available for this option yet.")).toBeNull(); + }); + + it("waits for the market estimate before taking one", async () => { + renderCard({ marketEstimate: undefined, isMarketPending: true }); + + await open(); + + expect(screen.getByRole("slider")).toBeDisabled(); + expect(screen.getByText("Loading the market estimate…")).toBeInTheDocument(); + }); + + it("keeps a fine-grained estimate through the field on a narrow scale", async () => { + const narrow = { ...option, unit: null, lowerBound: 0, upperBound: 1 }; + useEstimateStore.getState().setEstimate(PARENT, narrow.market, 0.12); + render( + , + ); + await open(); + + const field = screen.getByRole("textbox", { name: /Your estimate/ }); + await userEvent.click(field); + await userEvent.tab(); + expect(saved()).toBe(0.12); + + await userEvent.clear(field); + await userEvent.type(field, "0.45"); + await userEvent.tab(); + expect(saved()).toBe(0.45); }); it("shows the creator's content under details", async () => { - render(); + renderCard(); await open(); await userEvent.click(screen.getByText("Details")); @@ -70,11 +229,19 @@ describe("OptionCard", () => { }); it("reports an unreadable scale instead of drawing a slider", async () => { - render(); + render( + , + ); await open(); expect(screen.getByText("The scale for this option could not be read.")).toBeInTheDocument(); - expect(screen.queryByText("Setting estimates is not available yet.")).toBeNull(); + expect(screen.queryByRole("slider")).toBeNull(); }); }); diff --git a/apps/web/src/features/trade-executor/components/TradeWallet/WalletDialog.tsx b/apps/web/src/components/ui/Dialog.tsx similarity index 71% rename from apps/web/src/features/trade-executor/components/TradeWallet/WalletDialog.tsx rename to apps/web/src/components/ui/Dialog.tsx index 5cd37537..a36eda4c 100644 --- a/apps/web/src/features/trade-executor/components/TradeWallet/WalletDialog.tsx +++ b/apps/web/src/components/ui/Dialog.tsx @@ -1,33 +1,33 @@ import type { ReactNode } from "react"; import { Modal } from "@kleros/ui-components-library"; -import clsx from "clsx"; - -import LightButton from "@/components/ui/LightButton"; import CloseIcon from "@/assets/svg/close.svg"; -export function WalletDialog({ - isOpen, - onClose, - title, - hint, - children, -}: { +import { cn } from "@/utils/cn"; + +import LightButton from "./LightButton"; + +interface DialogProps { isOpen: boolean; onClose: () => void; title: string; hint: string; children: ReactNode; -}) { + className?: string; +} + +/** A dismissable modal with a centered title and hint, and a close button in the corner. */ +export function Dialog({ isOpen, onClose, title, hint, children, className }: DialogProps) { return ( (open ? undefined : onClose())} + className={cn( "relative h-fit w-max max-w-[calc(100vw-2rem)] overflow-x-hidden p-6 py-8", "shadow-default rounded-fs border-fs-border bg-fs-surface border", + className, )} > { + render( + +

body

+
, + ); + return onClose; +}; + +describe("Dialog", () => { + it("shows the title, hint and body", () => { + renderDialog(); + + expect(screen.getByRole("heading", { name: "Deposit" })).toBeInTheDocument(); + expect(screen.getByText("From your account")).toBeInTheDocument(); + expect(screen.getByText("body")).toBeInTheDocument(); + }); + + it("closes from the corner button", async () => { + const onClose = renderDialog(); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("closes when dismissed with Escape", async () => { + const onClose = renderDialog(); + + await userEvent.keyboard("{Escape}"); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/features/predict/components/EstimateActions.tsx b/apps/web/src/features/predict/components/EstimateActions.tsx new file mode 100644 index 00000000..93a6562b --- /dev/null +++ b/apps/web/src/features/predict/components/EstimateActions.tsx @@ -0,0 +1,47 @@ +import LightButton from "@/components/ui/LightButton"; + +import { fileStamp } from "@/utils/date"; +import { downloadTextFile } from "@/utils/download"; + +import type { OptionScale } from "@/types/optionScale"; + +import { useEstimateStore, useSessionEstimates } from "../stores/estimateStore"; +import { serializeEstimateCsv } from "../utils/estimateCsv"; +import { selectedEstimates } from "../utils/estimates"; + +interface EstimateActionsProps { + parentMarket: string; + scales: OptionScale[]; + marketEstimates: Record; + /** The creator's plural noun, already lowercased. */ + noun: string; +} + +/** Reset and export, for the estimates that would become predictions. Nothing until one is set. */ +export function EstimateActions({ parentMarket, scales, marketEstimates, noun }: EstimateActionsProps) { + const estimates = useSessionEstimates(parentMarket); + const resetEstimates = useEstimateStore((state) => state.resetEstimates); + + const selected = selectedEstimates(scales, estimates, marketEstimates); + if (selected.length === 0) return null; + const settable = scales.filter((scale) => marketEstimates[scale.market.toLowerCase()] !== undefined).length; + + const exportCsv = () => + downloadTextFile( + `estimates-${fileStamp(new Date())}.csv`, + serializeEstimateCsv(selected.map(({ scale, value }) => ({ name: scale.name, estimate: value }))), + "text/csv", + ); + + return ( +
+

+ {selected.length} of {settable} {noun} set +

+
+ resetEstimates(parentMarket)} /> + +
+
+ ); +} diff --git a/apps/web/src/features/predict/components/EstimateCsvDialog.tsx b/apps/web/src/features/predict/components/EstimateCsvDialog.tsx new file mode 100644 index 00000000..971bbf13 --- /dev/null +++ b/apps/web/src/features/predict/components/EstimateCsvDialog.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; + +import { Button, FileUploader } from "@kleros/ui-components-library"; + +import { Dialog } from "@/components/ui/Dialog"; +import LightButton from "@/components/ui/LightButton"; + +import { downloadTextFile } from "@/utils/download"; +import { successToast } from "@/utils/toast"; + +import type { OptionScale } from "@/types/optionScale"; + +import { useEstimateStore, useSessionEstimates } from "../stores/estimateStore"; +import { CSV_COLUMNS, parseEstimateCsv, serializeEstimateCsv } from "../utils/estimateCsv"; + +interface EstimateCsvDialogProps { + parentMarket: string; + scales: OptionScale[]; + marketEstimates: Record; + /** The creator's plural noun, already lowercased. */ + noun: string; + isDisabled?: boolean; +} + +/** A button and its dialog: estimates for every option at once, from a two-column CSV. */ +export function EstimateCsvDialog({ parentMarket, scales, marketEstimates, noun, isDisabled }: EstimateCsvDialogProps) { + const [isOpen, setIsOpen] = useState(false); + const [file, setFile] = useState(); + const [error, setError] = useState(); + + const estimates = useSessionEstimates(parentMarket); + const setEstimate = useEstimateStore((state) => state.setEstimate); + const clearEstimate = useEstimateStore((state) => state.clearEstimate); + + const close = () => { + setIsOpen(false); + setFile(undefined); + setError(undefined); + }; + + const templateRows = scales.map((scale) => ({ + name: scale.name, + estimate: estimates[scale.market.toLowerCase()] ?? marketEstimates[scale.market.toLowerCase()] ?? scale.lowerBound, + })); + + const load = async () => { + if (!file) return; + try { + const parsed = parseEstimateCsv(await file.text(), scales); + for (const { scale, value } of parsed) { + // a row at the market's own number is no prediction, same as the slider left there + if (value === marketEstimates[scale.market.toLowerCase()]) clearEstimate(parentMarket, scale.market); + else setEstimate(parentMarket, scale.market, value); + } + successToast("Estimates loaded."); + close(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "This file could not be read."); + } + }; + + return ( + <> +
+ + + ); +} diff --git a/apps/web/src/features/predict/components/EstimateSlider.tsx b/apps/web/src/features/predict/components/EstimateSlider.tsx new file mode 100644 index 00000000..56c96e6e --- /dev/null +++ b/apps/web/src/features/predict/components/EstimateSlider.tsx @@ -0,0 +1,120 @@ +import { NumberField, Slider } from "@kleros/ui-components-library"; +import clsx from "clsx"; + +import { getReadableTextColor } from "@/utils/branchColor"; +import { formatEstimate } from "@/utils/formatEstimate"; + +import type { OptionScale } from "@/types/optionScale"; + +import { estimateDigits, estimateDirection, estimateStep, snapEstimate } from "../utils/estimates"; + +interface EstimateSliderProps { + scale: OptionScale; + /** Where the market sits now; undefined where the option has no trading. */ + marketEstimate: number | undefined; + isMarketPending: boolean; + /** The saved estimate, if one is set. */ + value: number | undefined; + onCommit: (value: number) => void; + onClear: () => void; + /** Set when the session as a whole cannot take estimates; shown in place of the input. */ + lockedReason?: string; +} + +/** + * An estimate on the option's own scale: the market's number marked on the track, the + * visitor's as the thumb, and a field for typing it exactly. + */ +export function EstimateSlider({ + scale, + marketEstimate, + isMarketPending, + value, + onCommit, + onClear, + lockedReason, +}: EstimateSliderProps) { + const range = scale.upperBound - scale.lowerBound; + // react-aria snaps a controlled value to the step, so the market's number is held on the same grid + const step = estimateStep(scale); + const shown = value ?? marketEstimate ?? scale.lowerBound + range / 2; + const reason = + lockedReason ?? + (marketEstimate === undefined && !isMarketPending ? "No trading is available for this option yet." : undefined); + const isDisabled = reason !== undefined || isMarketPending; + + const commit = (next: number) => { + const snapped = snapEstimate(next, scale); + if (marketEstimate !== undefined && snapped === marketEstimate) onClear(); + else onCommit(snapped); + }; + + const direction = + value !== undefined && marketEstimate !== undefined ? estimateDirection(value, marketEstimate) : null; + const marketAt = marketEstimate === undefined ? undefined : ((marketEstimate - scale.lowerBound) / range) * 100; + + return ( +
+
+ formatEstimate(amount, scale.unit)} + callback={commit} + isDisabled={isDisabled} + /> +
+ {marketAt !== undefined ? ( +
+ + + Market {formatEstimate(marketEstimate!, scale.unit)} + +
+ ) : null} +
+
+ {formatEstimate(scale.lowerBound, scale.unit)} + {formatEstimate(scale.upperBound, scale.unit)} +
+
+ +
+ (Number.isNaN(typed) ? onClear() : commit(typed))} + isDisabled={isDisabled} + className="w-44" + /> +

+ {reason ?? + (isMarketPending ? ( + "Loading the market estimate…" + ) : direction === "higher" ? ( + Higher than the market estimate + ) : direction === "lower" ? ( + Lower than the market estimate + ) : ( + "At the market estimate. Move it to make a prediction." + ))} +

+
+
+ ); +} diff --git a/apps/web/src/features/predict/components/__tests__/EstimateActions.test.tsx b/apps/web/src/features/predict/components/__tests__/EstimateActions.test.tsx new file mode 100644 index 00000000..74f30692 --- /dev/null +++ b/apps/web/src/features/predict/components/__tests__/EstimateActions.test.tsx @@ -0,0 +1,75 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { downloadTextFile } from "@/utils/download"; + +import { addresses } from "@/testing/fixtures"; +import type { OptionScale } from "@/types/optionScale"; + +import { useEstimateStore } from "../../stores/estimateStore"; +import { EstimateActions } from "../EstimateActions"; + +vi.mock("@/utils/download", () => ({ downloadTextFile: vi.fn() })); + +const PARENT = addresses.parentMarket; +const [FIRST, SECOND] = addresses.childMarkets; + +const scales: OptionScale[] = [ + { market: FIRST, name: "Villeneuve", color: "#009aff", unit: "$M", lowerBound: 0, upperBound: 500 }, + { market: SECOND, name: "Gerwig", color: "#ff0000", unit: "$M", lowerBound: 0, upperBound: 500 }, +]; +const marketEstimates = { [FIRST]: 312, [SECOND]: 280 }; + +const renderActions = () => + render(); + +beforeEach(() => { + useEstimateStore.setState({ estimates: {} }); + vi.mocked(downloadTextFile).mockReset(); +}); + +describe("EstimateActions", () => { + it("shows nothing while no estimate differs from the market", () => { + useEstimateStore.getState().setEstimate(PARENT, FIRST, 312); + + const { container } = renderActions(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("counts the set estimates against the options that can take one", () => { + useEstimateStore.getState().setEstimate(PARENT, FIRST, 250); + + render( + , + ); + + expect(screen.getByText("1 of 1 directors set")).toBeInTheDocument(); + }); + + it("resets this session's estimates and leaves other sessions alone", async () => { + useEstimateStore.getState().setEstimate(PARENT, FIRST, 250); + useEstimateStore.getState().setEstimate(addresses.deployer, FIRST, 250); + renderActions(); + + await userEvent.click(screen.getByRole("button", { name: "Reset estimates" })); + + expect(useEstimateStore.getState().estimates).toEqual({ [addresses.deployer]: { [FIRST]: 250 } }); + expect(screen.queryByText(/directors set/)).toBeNull(); + }); + + it("exports the set estimates as a CSV stamped with the local date and time", async () => { + useEstimateStore.getState().setEstimate(PARENT, SECOND, 300); + useEstimateStore.getState().setEstimate(PARENT, FIRST, 250); + renderActions(); + + await userEvent.click(screen.getByRole("button", { name: "Export estimates" })); + + expect(downloadTextFile).toHaveBeenCalledWith( + expect.stringMatching(/^estimates-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}\.csv$/), + "name,estimate\nVilleneuve,250\nGerwig,300", + "text/csv", + ); + }); +}); diff --git a/apps/web/src/features/predict/components/__tests__/EstimateCsvDialog.test.tsx b/apps/web/src/features/predict/components/__tests__/EstimateCsvDialog.test.tsx new file mode 100644 index 00000000..64899769 --- /dev/null +++ b/apps/web/src/features/predict/components/__tests__/EstimateCsvDialog.test.tsx @@ -0,0 +1,95 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { downloadTextFile } from "@/utils/download"; +import { successToast } from "@/utils/toast"; + +import { addresses } from "@/testing/fixtures"; +import type { OptionScale } from "@/types/optionScale"; + +import { useEstimateStore } from "../../stores/estimateStore"; +import { EstimateCsvDialog } from "../EstimateCsvDialog"; + +vi.mock("@/utils/download", () => ({ downloadTextFile: vi.fn() })); +vi.mock("@/utils/toast", () => ({ successToast: vi.fn() })); + +const PARENT = addresses.parentMarket; +const [FIRST, SECOND] = addresses.childMarkets; + +const scales: OptionScale[] = [ + { market: FIRST, name: "Villeneuve", color: "#009aff", unit: "$M", lowerBound: 0, upperBound: 500 }, + { market: SECOND, name: "Gerwig", color: "#ff0000", unit: "$M", lowerBound: 0, upperBound: 500 }, +]; +const marketEstimates = { [FIRST]: 312, [SECOND]: 280 }; + +const csvFile = (text: string) => new File([text], "estimates.csv", { type: "text/csv" }); + +async function openDialog() { + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Upload CSV predictions" })); +} + +/** react-aria's file trigger hides its input; the upload goes straight to it. */ +const fileInput = () => document.querySelector('input[type="file"]')!; + +beforeEach(() => { + useEstimateStore.setState({ estimates: {} }); + vi.mocked(downloadTextFile).mockReset(); + vi.mocked(successToast).mockReset(); +}); + +describe("EstimateCsvDialog", () => { + it("loads every row into the session's estimates and closes", async () => { + await openDialog(); + + await userEvent.upload(fileInput(), csvFile("name,estimate\nVilleneuve,250\nGerwig,300")); + await userEvent.click(screen.getByRole("button", { name: "Load estimates" })); + + expect(useEstimateStore.getState().estimates[PARENT]).toEqual({ [FIRST]: 250, [SECOND]: 300 }); + expect(successToast).toHaveBeenCalledWith("Estimates loaded."); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("treats a row at the market's own number as no estimate", async () => { + useEstimateStore.getState().setEstimate(PARENT, FIRST, 250); + await openDialog(); + + await userEvent.upload(fileInput(), csvFile("name,estimate\nVilleneuve,312\nGerwig,300")); + await userEvent.click(screen.getByRole("button", { name: "Load estimates" })); + + expect(useEstimateStore.getState().estimates[PARENT]).toEqual({ [SECOND]: 300 }); + }); + + it("refuses a bad file, names the row, and keeps the dialog open with nothing loaded", async () => { + await openDialog(); + + await userEvent.upload(fileInput(), csvFile("name,estimate\nVilleneuve,250\nNolan,250")); + await userEvent.click(screen.getByRole("button", { name: "Load estimates" })); + + expect(screen.getByText('Row 3: there is no option named "Nolan".')).toBeInTheDocument(); + expect(useEstimateStore.getState().estimates).toEqual({}); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("offers a template with every option at your estimate where set and the market's otherwise", async () => { + useEstimateStore.getState().setEstimate(PARENT, FIRST, 250); + await openDialog(); + + await userEvent.click(screen.getByRole("button", { name: "Download CSV template" })); + + expect(downloadTextFile).toHaveBeenCalledWith( + "estimates-template.csv", + "name,estimate\nVilleneuve,250\nGerwig,280", + "text/csv", + ); + }); + + it("cannot load before a file is chosen", async () => { + await openDialog(); + + expect(screen.getByRole("button", { name: "Load estimates" })).toBeDisabled(); + }); +}); diff --git a/apps/web/src/features/predict/hooks/__tests__/useEstimateHydration.test.tsx b/apps/web/src/features/predict/hooks/__tests__/useEstimateHydration.test.tsx new file mode 100644 index 00000000..ddf9d1b1 --- /dev/null +++ b/apps/web/src/features/predict/hooks/__tests__/useEstimateHydration.test.tsx @@ -0,0 +1,67 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { addresses } from "@/testing/fixtures"; + +import { ESTIMATES_STORAGE_KEY, useEstimateStore } from "../../stores/estimateStore"; +import { useEstimateHydration } from "../useEstimateHydration"; + +const PARENT = addresses.parentMarket; +const [FIRST] = addresses.childMarkets; + +/** What another tab leaves in storage after setting one estimate. */ +const savedByOtherTab = (value: number) => + JSON.stringify({ state: { estimates: { [PARENT]: { [FIRST]: value } } }, version: 0 }); + +beforeEach(() => { + localStorage.clear(); + useEstimateStore.setState({ estimates: {} }); +}); + +describe("useEstimateHydration", () => { + it("loads what was saved once mounted", async () => { + localStorage.setItem(ESTIMATES_STORAGE_KEY, savedByOtherTab(250)); + + renderHook(() => useEstimateHydration()); + await act(async () => {}); + + expect(useEstimateStore.getState().estimates[PARENT]).toEqual({ [FIRST]: 250 }); + }); + + it("takes another tab's write instead of overwriting it later", async () => { + renderHook(() => useEstimateHydration()); + await act(async () => {}); + + localStorage.setItem(ESTIMATES_STORAGE_KEY, savedByOtherTab(300)); + await act(async () => { + window.dispatchEvent(new StorageEvent("storage", { key: ESTIMATES_STORAGE_KEY })); + }); + + expect(useEstimateStore.getState().estimates[PARENT]).toEqual({ [FIRST]: 300 }); + }); + + it("lets storage being cleared elsewhere clear this tab too", async () => { + localStorage.setItem(ESTIMATES_STORAGE_KEY, savedByOtherTab(250)); + renderHook(() => useEstimateHydration()); + await act(async () => {}); + + localStorage.clear(); + await act(async () => { + window.dispatchEvent(new StorageEvent("storage", { key: null })); + }); + + expect(useEstimateStore.getState().estimates).toEqual({}); + }); + + it("ignores writes to other keys", async () => { + renderHook(() => useEstimateHydration()); + await act(async () => {}); + + localStorage.setItem(ESTIMATES_STORAGE_KEY, savedByOtherTab(300)); + await act(async () => { + window.dispatchEvent(new StorageEvent("storage", { key: "something-else" })); + }); + + expect(useEstimateStore.getState().estimates).toEqual({}); + }); +}); diff --git a/apps/web/src/features/predict/hooks/useEstimateHydration.ts b/apps/web/src/features/predict/hooks/useEstimateHydration.ts new file mode 100644 index 00000000..ee97c36b --- /dev/null +++ b/apps/web/src/features/predict/hooks/useEstimateHydration.ts @@ -0,0 +1,20 @@ +import { useEffect } from "react"; + +import { ESTIMATES_STORAGE_KEY, useEstimateStore } from "../stores/estimateStore"; + +/** + * Loads saved estimates once the page is on screen (see `skipHydration` on the store), and + * again whenever another tab writes them, so two tabs on one session do not overwrite each other. + */ +export function useEstimateHydration() { + useEffect(() => { + void useEstimateStore.persist.rehydrate(); + + const onStorage = (event: StorageEvent) => { + // a null key is storage being cleared elsewhere + if (event.key === ESTIMATES_STORAGE_KEY || event.key === null) void useEstimateStore.persist.rehydrate(); + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, []); +} diff --git a/apps/web/src/features/predict/stores/__tests__/estimateStore.test.ts b/apps/web/src/features/predict/stores/__tests__/estimateStore.test.ts new file mode 100644 index 00000000..01bd8596 --- /dev/null +++ b/apps/web/src/features/predict/stores/__tests__/estimateStore.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { addresses } from "@/testing/fixtures"; + +import { ESTIMATES_STORAGE_KEY, normalizeEstimates, useEstimateStore } from "../estimateStore"; + +const PARENT = addresses.parentMarket; +const [FIRST, SECOND] = addresses.childMarkets; + +const store = () => useEstimateStore.getState(); + +beforeEach(() => { + localStorage.clear(); + useEstimateStore.setState({ estimates: {} }); +}); + +describe("estimateStore", () => { + it("keeps one estimate per option per session", () => { + store().setEstimate(PARENT, FIRST, 250); + store().setEstimate(PARENT, SECOND, 300); + store().setEstimate(addresses.deployer, FIRST, 1); + + expect(store().estimates[PARENT]).toEqual({ [FIRST]: 250, [SECOND]: 300 }); + }); + + it("stores under lowercased addresses whatever casing it is given", () => { + store().setEstimate(PARENT.toUpperCase(), FIRST.replace("0xc", "0xC"), 250); + + expect(store().estimates[PARENT]).toEqual({ [FIRST]: 250 }); + }); + + it("clears one option and forgets the session once it is empty", () => { + store().setEstimate(PARENT, FIRST, 250); + store().setEstimate(PARENT, SECOND, 300); + + store().clearEstimate(PARENT, FIRST); + expect(store().estimates[PARENT]).toEqual({ [SECOND]: 300 }); + + store().clearEstimate(PARENT, SECOND); + expect(store().estimates).toEqual({}); + }); + + it("resets one session and leaves the others alone", () => { + store().setEstimate(PARENT, FIRST, 250); + store().setEstimate(addresses.deployer, FIRST, 1); + + store().resetEstimates(PARENT); + + expect(store().estimates).toEqual({ [addresses.deployer]: { [FIRST]: 1 } }); + }); + + it("comes back from storage after an explicit rehydrate", async () => { + store().setEstimate(PARENT, FIRST, 250); + // the store writes on every set, so the saved copy is taken before the state is emptied + const saved = localStorage.getItem(ESTIMATES_STORAGE_KEY)!; + useEstimateStore.setState({ estimates: {} }); + localStorage.setItem(ESTIMATES_STORAGE_KEY, saved); + + await useEstimateStore.persist.rehydrate(); + + expect(store().estimates[PARENT]).toEqual({ [FIRST]: 250 }); + }); + + it("drops a saved shape it cannot trust rather than crashing", async () => { + localStorage.setItem( + ESTIMATES_STORAGE_KEY, + JSON.stringify({ state: { estimates: { [PARENT]: { [FIRST]: "250" }, junk: 1 } }, version: 0 }), + ); + + await useEstimateStore.persist.rehydrate(); + + expect(store().estimates).toEqual({}); + }); +}); + +describe("normalizeEstimates", () => { + it("keeps finite numbers under address-shaped keys", () => { + expect(normalizeEstimates({ [PARENT]: { [FIRST]: 250 } })).toEqual({ [PARENT]: { [FIRST]: 250 } }); + }); + + it("drops anything that is not a finite number under an address", () => { + const junk = { + [PARENT]: { [FIRST]: "250", [SECOND]: Number.NaN, notAnAddress: 1 }, + notAnAddress: { [FIRST]: 1 }, + [addresses.deployer]: null, + }; + + expect(normalizeEstimates(junk)).toEqual({}); + }); + + it("lowercases keys that were saved in checksum form", () => { + const upper = { [PARENT.replace("0xf", "0xF")]: { [FIRST.replace("0xc", "0xC")]: 250 } }; + + expect(normalizeEstimates(upper)).toEqual({ [PARENT]: { [FIRST]: 250 } }); + }); + + it("returns nothing for a shape that is not an object", () => { + expect(normalizeEstimates(undefined)).toEqual({}); + expect(normalizeEstimates("estimates")).toEqual({}); + }); +}); diff --git a/apps/web/src/features/predict/stores/estimateStore.ts b/apps/web/src/features/predict/stores/estimateStore.ts new file mode 100644 index 00000000..fc07bc7f --- /dev/null +++ b/apps/web/src/features/predict/stores/estimateStore.ts @@ -0,0 +1,104 @@ +import * as v from "valibot"; +import { isAddress } from "viem"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +const AddressKey = v.pipe( + v.string(), + v.check((key) => isAddress(key, { strict: false })), +); +const Estimate = v.pipe(v.number(), v.finite()); + +/** Keyed by lowercased parent market, then lowercased child market. */ +const EstimatesSchema = v.record(AddressKey, v.record(AddressKey, Estimate)); +type Estimates = v.InferOutput; + +const lowercaseKeys = (record: Estimates): Estimates => + Object.fromEntries( + Object.entries(record).map(([parent, options]) => [ + parent.toLowerCase(), + Object.fromEntries(Object.entries(options).map(([market, estimate]) => [market.toLowerCase(), estimate])), + ]), + ); + +/** + * Persisted state is untrusted. A record that fits the schema is taken whole; otherwise it is + * checked entry by entry so one bad value costs itself, not everything saved. + */ +export function normalizeEstimates(value: unknown): Estimates { + if (v.is(EstimatesSchema, value)) return lowercaseKeys(value); + + const sessions: Estimates = {}; + if (!value || typeof value !== "object") return sessions; + + for (const [parent, saved] of Object.entries(value as Record)) { + if (!v.is(AddressKey, parent) || !saved || typeof saved !== "object") continue; + + const options: Estimates[string] = {}; + for (const [market, estimate] of Object.entries(saved as Record)) { + if (v.is(AddressKey, market) && v.is(Estimate, estimate)) options[market.toLowerCase()] = estimate; + } + if (Object.keys(options).length > 0) sessions[parent.toLowerCase()] = options; + } + + return sessions; +} + +type EstimateStore = { + estimates: Estimates; + setEstimate: (parentMarket: string, market: string, value: number) => void; + clearEstimate: (parentMarket: string, market: string) => void; + resetEstimates: (parentMarket: string) => void; +}; + +const key = (address: string) => address.toLowerCase(); + +const EMPTY: Record = {}; + +export const ESTIMATES_STORAGE_KEY = "foresight-estimates"; + +export const useEstimateStore = create()( + persist( + (set) => ({ + estimates: {}, + + setEstimate: (parentMarket, market, value) => + set((state) => { + const parent = key(parentMarket); + return { + estimates: { ...state.estimates, [parent]: { ...state.estimates[parent], [key(market)]: value } }, + }; + }), + + clearEstimate: (parentMarket, market) => + set((state) => { + const parent = key(parentMarket); + const { [key(market)]: _cleared, ...rest } = state.estimates[parent] ?? {}; + const { [parent]: _session, ...sessions } = state.estimates; + return { estimates: Object.keys(rest).length > 0 ? { ...sessions, [parent]: rest } : sessions }; + }), + + resetEstimates: (parentMarket) => + set((state) => { + const { [key(parentMarket)]: _session, ...sessions } = state.estimates; + return { estimates: sessions }; + }), + }), + { + name: ESTIMATES_STORAGE_KEY, + storage: createJSONStorage(() => localStorage), + // The server renders every option unset; hydrating after mount keeps the first client render identical. + skipHydration: true, + merge: (persisted, current) => ({ + ...current, + estimates: normalizeEstimates((persisted as { estimates?: unknown } | undefined)?.estimates), + }), + }, + ), +); + +export const useSessionEstimates = (parentMarket: string) => + useEstimateStore((state) => state.estimates[key(parentMarket)] ?? EMPTY); + +export const useEstimate = (parentMarket: string, market: string) => + useEstimateStore((state) => state.estimates[key(parentMarket)]?.[key(market)]); diff --git a/apps/web/src/features/predict/types.ts b/apps/web/src/features/predict/types.ts new file mode 100644 index 00000000..ab69b2a9 --- /dev/null +++ b/apps/web/src/features/predict/types.ts @@ -0,0 +1,9 @@ +import type { OptionScale } from "@/types/optionScale"; + +/** An estimate that differs from the market's, which is what a prediction is. */ +export interface SelectedEstimate { + scale: OptionScale; + value: number; + marketEstimate: number; + direction: "higher" | "lower"; +} diff --git a/apps/web/src/features/predict/utils/__tests__/estimateCsv.test.ts b/apps/web/src/features/predict/utils/__tests__/estimateCsv.test.ts new file mode 100644 index 00000000..e29bf314 --- /dev/null +++ b/apps/web/src/features/predict/utils/__tests__/estimateCsv.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; + +import { addresses } from "@/testing/fixtures"; +import type { OptionScale } from "@/types/optionScale"; + +import { parseEstimateCsv, serializeEstimateCsv } from "../estimateCsv"; + +const [FIRST, SECOND] = addresses.childMarkets; + +const villeneuve: OptionScale = { + market: FIRST, + name: "Villeneuve", + color: "#009aff", + unit: "$M", + lowerBound: 0, + upperBound: 500, +}; +const gerwig: OptionScale = { + market: SECOND, + name: "Gerwig, Greta", + color: "#ff0000", + unit: "$M", + lowerBound: 0, + upperBound: 500, +}; +const scales = [villeneuve, gerwig]; + +describe("parseEstimateCsv rows", () => { + it("splits rows on either line ending", () => { + expect(parseEstimateCsv('name,estimate\r\nVilleneuve,250\n"Gerwig, Greta",300', scales)).toEqual([ + { scale: villeneuve, value: 250 }, + { scale: gerwig, value: 300 }, + ]); + }); + + it("keeps commas and doubled quotes inside a quoted field", () => { + const quoted = { ...gerwig, name: 'Gerwig, "Greta"' }; + + expect(parseEstimateCsv('name,estimate\n"Gerwig, ""Greta""",300', [villeneuve, quoted])).toEqual([ + { scale: quoted, value: 300 }, + ]); + }); + + it("skips blank lines", () => { + expect(parseEstimateCsv("name,estimate\n\n ,\nVilleneuve,250\n", scales)).toEqual([ + { scale: villeneuve, value: 250 }, + ]); + }); +}); + +describe("serializeEstimateCsv", () => { + it("writes the header and quotes a name that carries a comma", () => { + const csv = serializeEstimateCsv([ + { name: "Villeneuve", estimate: 250 }, + { name: "Gerwig, Greta", estimate: 300.5 }, + ]); + + expect(csv).toBe('name,estimate\nVilleneuve,250\n"Gerwig, Greta",300.5'); + }); + + it("round-trips through the parser", () => { + const csv = serializeEstimateCsv([ + { name: "Villeneuve", estimate: 250 }, + { name: "Gerwig, Greta", estimate: 300 }, + ]); + + expect(parseEstimateCsv(csv, scales)).toEqual([ + { scale: villeneuve, value: 250 }, + { scale: gerwig, value: 300 }, + ]); + }); +}); + +describe("parseEstimateCsv", () => { + it("matches names without regard to case or surrounding spaces", () => { + expect(parseEstimateCsv("name,estimate\n villeneuve ,250", scales)).toEqual([{ scale: villeneuve, value: 250 }]); + }); + + it("accepts the columns in either order", () => { + expect(parseEstimateCsv("estimate,name\n250,Villeneuve", scales)).toEqual([{ scale: villeneuve, value: 250 }]); + }); + + it("keeps the last row when an option appears twice", () => { + expect(parseEstimateCsv("name,estimate\nVilleneuve,250\nVilleneuve,260", scales)).toEqual([ + { scale: villeneuve, value: 260 }, + ]); + }); + + it("refuses a header without both columns", () => { + expect(() => parseEstimateCsv("option,score\nVilleneuve,250", scales)).toThrow( + "exactly two columns: name,estimate", + ); + }); + + it("refuses a header with a third column", () => { + expect(() => parseEstimateCsv("name,estimate,note\nVilleneuve,250,x", scales)).toThrow("exactly two columns"); + }); + + it("refuses an estimate finer than the scale's step rather than rounding it", () => { + expect(() => parseEstimateCsv("name,estimate\nVilleneuve,312.37", scales)).toThrow( + "Row 2: Villeneuve moves in steps of 1, so 312.37 is not a value it can take.", + ); + }); + + it("reads a scale in the tens of thousands, whose step is a hundred", () => { + const wide: OptionScale = { ...villeneuve, lowerBound: 0, upperBound: 10_000 }; + + expect(parseEstimateCsv("name,estimate\nVilleneuve,4300", [wide])).toEqual([{ scale: wide, value: 4300 }]); + expect(() => parseEstimateCsv("name,estimate\nVilleneuve,4321", [wide])).toThrow("steps of 100"); + // a thousands separator survives only inside quotes, and is then not a number + expect(() => parseEstimateCsv('name,estimate\nVilleneuve,"4,300"', [wide])).toThrow('"4,300" is not a number'); + }); + + it("refuses a file with only a header", () => { + expect(() => parseEstimateCsv("name,estimate\n", scales)).toThrow("no rows"); + }); + + it("names the row that has no option by that name", () => { + expect(() => parseEstimateCsv("name,estimate\nVilleneuve,250\nNolan,250", scales)).toThrow( + 'Row 3: there is no option named "Nolan"', + ); + }); + + it("names the row whose estimate is not a number", () => { + expect(() => parseEstimateCsv("name,estimate\nVilleneuve,lots", scales)).toThrow('Row 2: "lots" is not a number'); + }); + + it("names the row whose estimate is off the option's scale", () => { + expect(() => parseEstimateCsv("name,estimate\nVilleneuve,501", scales)).toThrow( + "Row 2: Villeneuve takes 0 to 500, not 501", + ); + }); + + it("names the row missing a value", () => { + expect(() => parseEstimateCsv("name,estimate\nVilleneuve,", scales)).toThrow("Row 2: both columns need a value"); + }); +}); diff --git a/apps/web/src/features/predict/utils/__tests__/estimates.test.ts b/apps/web/src/features/predict/utils/__tests__/estimates.test.ts new file mode 100644 index 00000000..f6a75fb6 --- /dev/null +++ b/apps/web/src/features/predict/utils/__tests__/estimates.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; + +import { addresses } from "@/testing/fixtures"; +import type { OptionScale } from "@/types/optionScale"; + +import { estimateDigits, estimateDirection, estimateStep, selectedEstimates, snapEstimate } from "../estimates"; + +const scale = (market: OptionScale["market"], name = "Option"): OptionScale => ({ + market, + name, + color: "#009aff", + unit: "$M", + lowerBound: 0, + upperBound: 500, +}); + +const [FIRST, SECOND] = addresses.childMarkets; + +describe("snapEstimate", () => { + it("moves in a power of ten two orders below the scale's width", () => { + expect(estimateStep({ lowerBound: 0, upperBound: 500 })).toBe(1); + expect(estimateStep({ lowerBound: 0, upperBound: 10 })).toBe(0.1); + expect(estimateStep({ lowerBound: 0, upperBound: 1 })).toBe(0.01); + expect(estimateStep({ lowerBound: 0, upperBound: 10_000 })).toBe(100); + expect(estimateStep({ lowerBound: 0, upperBound: 0 })).toBe(0); + }); + + it("puts a value on the scale's grid", () => { + expect(snapEstimate(312.345, scale(FIRST))).toBe(312); + expect(snapEstimate(312.6, scale(FIRST))).toBe(313); + expect(snapEstimate(4321, { lowerBound: 0, upperBound: 10_000 })).toBe(4300); + expect(snapEstimate(0.126, { lowerBound: 0, upperBound: 1 })).toBe(0.13); + }); + + it("keeps a value inside the scale's bounds", () => { + expect(snapEstimate(600, scale(FIRST))).toBe(500); + expect(snapEstimate(-1, scale(FIRST))).toBe(0); + }); + + it("leaves no binary debris on a fractional grid", () => { + expect(snapEstimate(0.35, { lowerBound: 0.1, upperBound: 0.7 })).toBe(0.35); + expect(String(snapEstimate(3.3, { lowerBound: 0, upperBound: 10 }))).toBe("3.3"); + }); + + it("falls to the lower bound on a scale with no width", () => { + expect(snapEstimate(3, { lowerBound: 0, upperBound: 0 })).toBe(0); + }); +}); + +describe("estimateDigits", () => { + it("shows as many fraction digits as the grid needs", () => { + expect(estimateDigits({ lowerBound: 0, upperBound: 500 })).toBe(0); + expect(estimateDigits({ lowerBound: 0, upperBound: 10 })).toBe(1); + expect(estimateDigits({ lowerBound: 0, upperBound: 1 })).toBe(2); + expect(estimateDigits({ lowerBound: 1_000_000, upperBound: 5_000_000 })).toBe(0); + expect(estimateDigits({ lowerBound: 0, upperBound: 0 })).toBe(0); + }); +}); + +describe("estimateDirection", () => { + it("reads higher, lower, or nothing against the market", () => { + expect(estimateDirection(320, 312)).toBe("higher"); + expect(estimateDirection(300, 312)).toBe("lower"); + expect(estimateDirection(312, 312)).toBeNull(); + }); +}); + +describe("selectedEstimates", () => { + const scales = [scale(FIRST, "Villeneuve"), scale(SECOND, "Gerwig")]; + const markets = { [FIRST]: 312, [SECOND]: 280 }; + + it("keeps the estimates that differ from the market, in option order", () => { + const selected = selectedEstimates(scales, { [SECOND]: 300, [FIRST]: 250 }, markets); + + expect(selected.map((s) => [s.scale.name, s.value, s.direction])).toEqual([ + ["Villeneuve", 250, "lower"], + ["Gerwig", 300, "higher"], + ]); + }); + + it("drops an estimate that sits exactly where the market does", () => { + expect(selectedEstimates(scales, { [FIRST]: 312 }, markets)).toEqual([]); + }); + + it("drops an estimate on an option with no market estimate", () => { + expect(selectedEstimates(scales, { [FIRST]: 250 }, { [SECOND]: 280 })).toEqual([]); + }); + + it("matches stored keys whatever the option address casing", () => { + const checksummed = { ...scale(FIRST), market: FIRST.replace("0xc", "0xC") as OptionScale["market"] }; + + expect(selectedEstimates([checksummed], { [FIRST]: 250 }, markets)).toHaveLength(1); + }); +}); diff --git a/apps/web/src/features/predict/utils/estimateCsv.ts b/apps/web/src/features/predict/utils/estimateCsv.ts new file mode 100644 index 00000000..c60e042f --- /dev/null +++ b/apps/web/src/features/predict/utils/estimateCsv.ts @@ -0,0 +1,102 @@ +import type { OptionScale } from "@/types/optionScale"; + +import { estimateStep, snapEstimate } from "./estimates"; + +export const CSV_COLUMNS = ["name", "estimate"] as const; + +/** RFC 4180 rows: quoted fields may hold commas, newlines and doubled quotes. */ +function parseCsvRows(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ""; + let quoted = false; + + for (let i = 0; i < text.length; i++) { + const char = text[i]!; + if (quoted) { + if (char === '"') { + if (text[i + 1] === '"') { + field += '"'; + i++; + } else { + quoted = false; + } + } else { + field += char; + } + continue; + } + + if (char === '"') quoted = true; + else if (char === ",") { + row.push(field); + field = ""; + } else if (char === "\n" || char === "\r") { + if (char === "\r" && text[i + 1] === "\n") i++; + row.push(field); + rows.push(row); + row = []; + field = ""; + } else field += char; + } + if (field !== "" || row.length > 0) { + row.push(field); + rows.push(row); + } + + return rows.filter((cells) => cells.some((cell) => cell.trim() !== "")); +} + +const quote = (cell: string) => (/[",\r\n]/.test(cell) ? `"${cell.replaceAll('"', '""')}"` : cell); + +export function serializeEstimateCsv(rows: { name: string; estimate: number }[]): string { + return [CSV_COLUMNS.join(","), ...rows.map((row) => `${quote(row.name)},${row.estimate}`)].join("\n"); +} + +interface ParsedEstimate { + scale: OptionScale; + value: number; +} + +/** + * Rows are matched to options by name, and every row must land on an option, inside its scale + * and on its grid. Throws with the offending row rather than rounding or dropping anything quietly. + */ +export function parseEstimateCsv(text: string, scales: OptionScale[]): ParsedEstimate[] { + const [header, ...rows] = parseCsvRows(text); + const columns = header?.map((cell) => cell.trim().toLowerCase()) ?? []; + const nameAt = columns.indexOf(CSV_COLUMNS[0]); + const estimateAt = columns.indexOf(CSV_COLUMNS[1]); + if (columns.length !== 2 || nameAt === -1 || estimateAt === -1) { + throw new Error(`The first row must name exactly two columns: ${CSV_COLUMNS.join(",")}`); + } + if (rows.length === 0) throw new Error("There are no rows under the header."); + + const byName = new Map(scales.map((scale) => [scale.name.trim().toLowerCase(), scale])); + const parsed = new Map(); + + rows.forEach((cells, index) => { + const line = index + 2; + const name = (cells[nameAt] ?? "").trim(); + const raw = (cells[estimateAt] ?? "").trim(); + if (!name || !raw) throw new Error(`Row ${line}: both columns need a value.`); + + const scale = byName.get(name.toLowerCase()); + if (!scale) throw new Error(`Row ${line}: there is no option named "${name}".`); + + const value = Number(raw); + if (!Number.isFinite(value)) throw new Error(`Row ${line}: "${raw}" is not a number.`); + if (value < scale.lowerBound || value > scale.upperBound) { + throw new Error(`Row ${line}: ${scale.name} takes ${scale.lowerBound} to ${scale.upperBound}, not ${raw}.`); + } + if (snapEstimate(value, scale) !== value) { + throw new Error( + `Row ${line}: ${scale.name} moves in steps of ${estimateStep(scale)}, so ${raw} is not a value it can take.`, + ); + } + + parsed.set(scale.market.toLowerCase(), { scale, value }); + }); + + return [...parsed.values()]; +} diff --git a/apps/web/src/features/predict/utils/estimates.ts b/apps/web/src/features/predict/utils/estimates.ts new file mode 100644 index 00000000..e703a696 --- /dev/null +++ b/apps/web/src/features/predict/utils/estimates.ts @@ -0,0 +1,62 @@ +import type { OptionScale } from "@/types/optionScale"; + +import type { SelectedEstimate } from "../types"; + +/** + * The resolution every estimate on the page is held at: a power of ten two orders below the + * scale's width, so a 0-500 scale moves in 1s, 0-10 in 0.1s, 0-1 in 0.01s (100 to 999 steps). + */ +export function estimateStep(scale: Pick): number { + const range = scale.upperBound - scale.lowerBound; + return range > 0 ? 10 ** (Math.floor(Math.log10(range)) - 2) : 0; +} + +/** Fraction digits that show every grid point; a coarser field would round a saved value on blur. */ +export function estimateDigits(scale: Pick): number { + const step = estimateStep(scale); + return step > 0 ? Math.max(0, Math.ceil(-Math.log10(step))) : 0; +} + +/** + * Puts a value on the option's grid and inside its bounds. Applied to the market's number as + * well as the visitor's, so "at the market" means the same thing however the value arrived. + */ +export function snapEstimate(value: number, scale: Pick): number { + const step = estimateStep(scale); + if (step <= 0) return scale.lowerBound; + + const snapped = scale.lowerBound + Math.round((value - scale.lowerBound) / step) * step; + // the multiply leaves binary debris (0.35019999999999996) that would reach a CSV as-is + return Number(Math.min(scale.upperBound, Math.max(scale.lowerBound, snapped)).toPrecision(12)); +} + +export function estimateDirection(value: number, marketEstimate: number): SelectedEstimate["direction"] | null { + if (value > marketEstimate) return "higher"; + if (value < marketEstimate) return "lower"; + return null; +} + +/** + * The estimates that would become predictions: set on an option that is trading, and away + * from where the market already sits. Order follows `scales`. + */ +export function selectedEstimates( + scales: OptionScale[], + estimates: Record, + marketEstimates: Record, +): SelectedEstimate[] { + const selected: SelectedEstimate[] = []; + for (const scale of scales) { + const key = scale.market.toLowerCase(); + const value = estimates[key]; + const marketEstimate = marketEstimates[key]; + if (value === undefined || marketEstimate === undefined) continue; + + const direction = estimateDirection(value, marketEstimate); + if (direction === null) continue; + + selected.push({ scale, value, marketEstimate, direction }); + } + + return selected; +} diff --git a/apps/web/src/features/sessions/types.ts b/apps/web/src/features/sessions/types.ts index af73824a..27b4ad1f 100644 --- a/apps/web/src/features/sessions/types.ts +++ b/apps/web/src/features/sessions/types.ts @@ -1,6 +1,8 @@ import type { DisplayBlock } from "@foresight/session-metadata"; import type { Address } from "viem"; +import type { OptionScale } from "@/types/optionScale"; + export type SessionStatus = "setting-up" | "trading" | "closed"; /** The fields every session read shares, whichever query fetched it. */ @@ -21,17 +23,9 @@ export interface SessionCore { } /** One child market */ -export interface SessionOption { - market: Address; +export interface SessionOption extends OptionScale { outcomeIndex: number; - name: string; - color: string; question: string; - /** Parsed from the market name's `[unit]` suffix; null where the name carries none. */ - unit: string | null; - /** Scalar bounds in the unit's own terms, de-scaled from 1e18. */ - lowerBound: number; - upperBound: number; upToken: Address | null; downToken: Address | null; underlyingToken: Address | null; @@ -61,10 +55,7 @@ export interface SessionDetail extends SessionCore { options: SessionOption[]; } -export type SessionChartOption = Pick< - SessionOption, - "market" | "name" | "color" | "unit" | "lowerBound" | "upperBound" ->; +export type SessionChartOption = OptionScale; export interface SessionChartPoint { /** Unix seconds. */ diff --git a/apps/web/src/features/sessions/utils/__tests__/latestEstimates.test.ts b/apps/web/src/features/sessions/utils/__tests__/latestEstimates.test.ts new file mode 100644 index 00000000..bdf028f8 --- /dev/null +++ b/apps/web/src/features/sessions/utils/__tests__/latestEstimates.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { addresses } from "@/testing/fixtures"; + +import type { SessionChartData } from "../../types"; +import { latestEstimates } from "../latestEstimates"; + +const option = (market: string) => ({ + market: market as never, + name: "x", + color: "#000", + unit: null, + lowerBound: 0, + upperBound: 500, +}); + +describe("latestEstimates", () => { + it("takes the newest point of each option, keyed by lowercased market", () => { + const upper = addresses.childMarkets[0].replace("0xc", "0xC"); + const data: SessionChartData = [ + { + option: option(upper), + points: [ + { timestamp: 1, value: 100 }, + { timestamp: 2, value: 312 }, + ], + }, + { option: option(addresses.childMarkets[1]), points: [{ timestamp: 1, value: 280 }] }, + ]; + + expect(latestEstimates(data)).toEqual({ [addresses.childMarkets[0]]: 312, [addresses.childMarkets[1]]: 280 }); + }); + + it("leaves out an option whose pool never priced", () => { + const data: SessionChartData = [{ option: option(addresses.childMarkets[0]), points: [] }]; + + expect(latestEstimates(data)).toEqual({}); + }); +}); diff --git a/apps/web/src/features/sessions/utils/latestEstimates.ts b/apps/web/src/features/sessions/utils/latestEstimates.ts new file mode 100644 index 00000000..11b5b52b --- /dev/null +++ b/apps/web/src/features/sessions/utils/latestEstimates.ts @@ -0,0 +1,14 @@ +import type { SessionChartData } from "../types"; + +/** + * Where the market sits now for each option, keyed by lowercased child market. An option whose + * pool never priced has no entry. + */ +export function latestEstimates(data: SessionChartData): Record { + const estimates: Record = {}; + for (const { option, points } of data) { + const last = points.at(-1); + if (last !== undefined) estimates[option.market.toLowerCase()] = last.value; + } + return estimates; +} diff --git a/apps/web/src/features/trade-executor/components/TradeWallet/AmountDialog.tsx b/apps/web/src/features/trade-executor/components/TradeWallet/AmountDialog.tsx index 629fd69b..5374bc03 100644 --- a/apps/web/src/features/trade-executor/components/TradeWallet/AmountDialog.tsx +++ b/apps/web/src/features/trade-executor/components/TradeWallet/AmountDialog.tsx @@ -2,10 +2,11 @@ import { useState, type FormEvent } from "react"; import { Button, Form } from "@kleros/ui-components-library"; +import { Dialog } from "@/components/ui/Dialog"; + import { parseAmount } from "@/utils/amount"; import { AmountField } from "./AmountField"; -import { WalletDialog } from "./WalletDialog"; interface AmountDialogProps { isOpen: boolean; @@ -48,7 +49,7 @@ export function AmountDialog({ }; return ( - +
-
+ ); } diff --git a/apps/web/src/features/trade-executor/components/TradeWallet/TransferDialog.tsx b/apps/web/src/features/trade-executor/components/TradeWallet/TransferDialog.tsx index b60929a1..ad27f4b4 100644 --- a/apps/web/src/features/trade-executor/components/TradeWallet/TransferDialog.tsx +++ b/apps/web/src/features/trade-executor/components/TradeWallet/TransferDialog.tsx @@ -3,12 +3,13 @@ import { useState, type FormEvent } from "react"; import { Button, Form } from "@kleros/ui-components-library"; import type { Address } from "viem"; +import { Dialog } from "@/components/ui/Dialog"; + import { parseAmount } from "@/utils/amount"; import type { Asset } from "@/lib/trade-executor/types"; import { TokenAmountField } from "./TokenAmountField"; -import { WalletDialog } from "./WalletDialog"; import { useFunds } from "../../api/getFunds"; export interface TokenChoice { @@ -71,7 +72,7 @@ export function TransferDialog({ }; return ( - +
-
+ ); } diff --git a/apps/web/src/types/optionScale.ts b/apps/web/src/types/optionScale.ts new file mode 100644 index 00000000..d08472de --- /dev/null +++ b/apps/web/src/types/optionScale.ts @@ -0,0 +1,13 @@ +import type { Address } from "viem"; + +/** One option's scale, as the creator set it: a name, a colour, a unit and its bounds. */ +export interface OptionScale { + market: Address; + name: string; + color: string; + /** Parsed from the market name's `[unit]` suffix; null where the name carries none. */ + unit: string | null; + /** Scalar bounds in the unit's own terms, de-scaled from 1e18. */ + lowerBound: number; + upperBound: number; +} diff --git a/apps/web/src/utils/__tests__/date.test.ts b/apps/web/src/utils/__tests__/date.test.ts index c59afc41..c70400a3 100644 --- a/apps/web/src/utils/__tests__/date.test.ts +++ b/apps/web/src/utils/__tests__/date.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { formatLocalInstant, formatUtcInstant, utcInstant } from "../date"; +import { fileStamp, formatUtcInstant, utcInstant } from "../date"; describe("utcInstant", () => { it("composes an ISO day and HH:mm into a UTC date", () => { @@ -52,3 +52,9 @@ describe("formatLocalInstant", () => { expect(formatLocalInstant(null)).toBe("not set"); }); }); + +describe("fileStamp", () => { + it("writes the local date and time with only filename-safe characters", () => { + expect(fileStamp(new Date(2026, 8, 5, 9, 7))).toBe("2026-09-05_09-07"); + }); +}); diff --git a/apps/web/src/utils/date.ts b/apps/web/src/utils/date.ts index 50613d0f..0363122b 100644 --- a/apps/web/src/utils/date.ts +++ b/apps/web/src/utils/date.ts @@ -67,3 +67,10 @@ export function timeLeftUntil(isoString: string): string { return `after ${targetDate.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}`; } + +const pad = (n: number) => String(n).padStart(2, "0"); + +/** Local date and time, safe in a filename: `2026-09-15_14-23`. */ +export function fileStamp(date: Date): string { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}`; +} diff --git a/apps/web/src/utils/download.ts b/apps/web/src/utils/download.ts new file mode 100644 index 00000000..99202d8a --- /dev/null +++ b/apps/web/src/utils/download.ts @@ -0,0 +1,10 @@ +/** Hands the browser a file to save, from text already in memory. */ +export function downloadTextFile(filename: string, content: string, type = "text/plain") { + const url = URL.createObjectURL(new Blob([content], { type })); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + // Firefox and Safari drop a download whose URL is revoked in the same tick + setTimeout(() => URL.revokeObjectURL(url), 0); +} From 118113eca04cc209fae2ae84246ebee36b2f1dc1 Mon Sep 17 00:00:00 2001 From: Harman-singh-waraich Date: Thu, 17 Sep 2026 21:03:52 +0530 Subject: [PATCH 002/621] chore: remove-token-fields --- .../_components/__tests__/MarketPage.test.tsx | 10 +++---- .../src/features/sessions/api/getSession.ts | 5 ---- .../sessions/utils/__tests__/fixtures.ts | 7 +---- apps/web/src/lib/graphql/generated/gql.ts | 6 ++-- apps/web/src/lib/graphql/generated/graphql.ts | 28 +++++++++++++++---- apps/web/src/utils/__tests__/date.test.ts | 2 +- 6 files changed, 32 insertions(+), 26 deletions(-) diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/MarketPage.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/MarketPage.test.tsx index 0dcbdf5c..efde9941 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/__tests__/MarketPage.test.tsx +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/MarketPage.test.tsx @@ -27,13 +27,13 @@ const sessionResult = { Session: [{ ...sessionDetailColumns, children: [] }] }; const queryName = (document: DocumentNode) => document.definitions.find((d): d is OperationDefinitionNode => d.kind === "OperationDefinition")?.name?.value; -/** Answers each query by name; the chart's answer (or a promise of it) comes from the caller. */ -function answerQueries(chart: unknown) { +/** Answers each query by name; the chart's answer is built per call, so a rejection exists only once a query adopts it. */ +function answerQueries(chart: () => unknown) { fetchMock.mockImplementation(async (document) => { if (queryName(document) === "SessionDetail") { return { Session: [{ ...sessionDetailColumns, children: [sessionChildRow()] }] }; } - if (queryName(document) === "SessionChart") return chart; + if (queryName(document) === "SessionChart") return chart(); return { Prediction: [], Pool: [] }; }); } @@ -94,7 +94,7 @@ describe("MarketPage", () => { /* The chart itself is canvas: rendered states would need the real component under jsdom or a stub asserting its own scaffolding, so they are verified live instead. */ it("says the price history failed instead of drawing nothing", async () => { - answerQueries(Promise.reject(new Error("boom"))); + answerQueries(() => Promise.reject(new Error("boom"))); renderPage(); @@ -102,7 +102,7 @@ describe("MarketPage", () => { }); it("holds the chart's place while the price history loads", async () => { - answerQueries(new Promise(() => {})); + answerQueries(() => new Promise(() => {})); const { container } = renderPage(); await screen.findByRole("heading", { level: 1, name: "Dune: Part Three, director" }); diff --git a/apps/web/src/features/sessions/api/getSession.ts b/apps/web/src/features/sessions/api/getSession.ts index 7e1be987..dacc07b6 100644 --- a/apps/web/src/features/sessions/api/getSession.ts +++ b/apps/web/src/features/sessions/api/getSession.ts @@ -48,11 +48,6 @@ const SESSION_DETAIL = graphql(` upToken downToken underlyingToken - tokens { - outcomeIndex - decimals - symbol - } } } } diff --git a/apps/web/src/features/sessions/utils/__tests__/fixtures.ts b/apps/web/src/features/sessions/utils/__tests__/fixtures.ts index cc6873bc..c87f199a 100644 --- a/apps/web/src/features/sessions/utils/__tests__/fixtures.ts +++ b/apps/web/src/features/sessions/utils/__tests__/fixtures.ts @@ -16,7 +16,7 @@ export const sessionColumns = { openingTime: dune.closesAtSeconds, } satisfies SessionCoreRow; -/** One deployed child with its token rows; a suite overrides what it asserts. */ +/** One deployed child; a suite overrides what it asserts. */ export function sessionChildRow(overrides: Partial = {}) { return { id: addresses.childMarkets[0], @@ -31,11 +31,6 @@ export function sessionChildRow(overrides: Partial; /** sessionId */ id: Scalars['String']['output']; - /** the parent's INVALID outcome token, which `outcomes` does not carry; merging the outcome set back to collateral needs it */ + /** the parent's INVALID outcome token */ invalidToken?: Maybe; itemName?: Maybe; itemNamePlural?: Maybe; @@ -1497,6 +1497,8 @@ export type Session = { /** when trading stops */ openingTime?: Maybe; outcomeCount: Scalars['numeric']['output']; + /** wrapped ERC-20 per named outcome */ + outcomeTokens: Array; outcomes: Array; parentMarket: Scalars['String']['output']; sessionId: Scalars['numeric']['output']; @@ -1592,6 +1594,7 @@ export type Session_Bool_Exp = { openedAt?: InputMaybe; openingTime?: InputMaybe; outcomeCount?: InputMaybe; + outcomeTokens?: InputMaybe; outcomes?: InputMaybe; parentMarket?: InputMaybe; sessionId?: InputMaybe; @@ -1623,6 +1626,7 @@ export type Session_Order_By = { openedAt?: InputMaybe; openingTime?: InputMaybe; outcomeCount?: InputMaybe; + outcomeTokens?: InputMaybe; outcomes?: InputMaybe; parentMarket?: InputMaybe; sessionId?: InputMaybe; @@ -1675,6 +1679,8 @@ export enum Session_Select_Column { /** column name */ OutcomeCount = 'outcomeCount', /** column name */ + OutcomeTokens = 'outcomeTokens', + /** column name */ Outcomes = 'outcomes', /** column name */ ParentMarket = 'parentMarket', @@ -1709,7 +1715,7 @@ export type Session_Stream_Cursor_Value_Input = { icon?: InputMaybe; /** sessionId */ id?: InputMaybe; - /** the parent's INVALID outcome token, which `outcomes` does not carry; merging the outcome set back to collateral needs it */ + /** the parent's INVALID outcome token */ invalidToken?: InputMaybe; itemName?: InputMaybe; itemNamePlural?: InputMaybe; @@ -1725,6 +1731,8 @@ export type Session_Stream_Cursor_Value_Input = { /** when trading stops */ openingTime?: InputMaybe; outcomeCount?: InputMaybe; + /** wrapped ERC-20 per named outcome */ + outcomeTokens?: InputMaybe>; outcomes?: InputMaybe>; parentMarket?: InputMaybe; sessionId?: InputMaybe; @@ -2126,12 +2134,17 @@ export type Swap_Variance_Order_By = { underlyingPerOutcomeAfter?: InputMaybe; }; -/** An outcome or underlying token of a child market. */ +/** + * An outcome or underlying token. An outcome token belongs to one child market; an underlying is + * a parent outcome token, priced against by every child conditional on that outcome, so its + * markets are the ChildMarket rows whose underlyingToken it is. + */ export type Token = { __typename?: 'Token'; /** An object relationship */ childMarket?: Maybe; - childMarket_id: Scalars['String']['output']; + /** the child market an outcome token belongs to; null for an underlying */ + childMarket_id?: Maybe; /** null where the on-chain read failed */ decimals?: Maybe; /** token address, lowercase */ @@ -2179,6 +2192,7 @@ export type Token_Bool_Exp = { /** order by max() on columns of table "Token" */ export type Token_Max_Order_By = { + /** the child market an outcome token belongs to; null for an underlying */ childMarket_id?: InputMaybe; /** null where the on-chain read failed */ decimals?: InputMaybe; @@ -2191,6 +2205,7 @@ export type Token_Max_Order_By = { /** order by min() on columns of table "Token" */ export type Token_Min_Order_By = { + /** the child market an outcome token belongs to; null for an underlying */ childMarket_id?: InputMaybe; /** null where the on-chain read failed */ decimals?: InputMaybe; @@ -2259,6 +2274,7 @@ export type Token_Stream_Cursor_Input = { /** Initial value of the column from where the streaming should start */ export type Token_Stream_Cursor_Value_Input = { + /** the child market an outcome token belongs to; null for an underlying */ childMarket_id?: InputMaybe; /** null where the on-chain read failed */ decimals?: InputMaybe; @@ -3386,7 +3402,7 @@ export type SessionDetailQueryVariables = Exact<{ }>; -export type SessionDetailQuery = { __typename?: 'query_root', Session: Array<{ __typename?: 'Session', id: string, parentMarket: string, deployer: string, marketName: string, title?: string | null, description?: string | null, icon?: string | null, heroImage?: string | null, itemName?: string | null, itemNamePlural?: string | null, metadataResolved: boolean, outcomeCount: string, deployedChildCount: string, invalidToken?: string | null, collateralToken?: string | null, collateralDecimals?: number | null, collateralSymbol?: string | null, openedAt: string, completedAt: string, openingTime?: string | null, blocks: unknown, children: Array<{ __typename?: 'ChildMarket', id: string, parentOutcomeIndex: string, parentOutcome: string, marketName: string, lowerBound: string, upperBound: string, displayName?: string | null, color?: string | null, blocks: unknown, upToken: string, downToken: string, underlyingToken: string, tokens: Array<{ __typename?: 'Token', outcomeIndex?: number | null, decimals?: number | null, symbol?: string | null }> }> }> }; +export type SessionDetailQuery = { __typename?: 'query_root', Session: Array<{ __typename?: 'Session', id: string, parentMarket: string, deployer: string, marketName: string, title?: string | null, description?: string | null, icon?: string | null, heroImage?: string | null, itemName?: string | null, itemNamePlural?: string | null, metadataResolved: boolean, outcomeCount: string, deployedChildCount: string, invalidToken?: string | null, collateralToken?: string | null, collateralDecimals?: number | null, collateralSymbol?: string | null, openedAt: string, completedAt: string, openingTime?: string | null, blocks: unknown, children: Array<{ __typename?: 'ChildMarket', id: string, parentOutcomeIndex: string, parentOutcome: string, marketName: string, lowerBound: string, upperBound: string, displayName?: string | null, color?: string | null, blocks: unknown, upToken: string, downToken: string, underlyingToken: string }> }> }; export type SessionChartQueryVariables = Exact<{ sessionId: Scalars['String']['input']; @@ -3415,7 +3431,7 @@ export type SessionsQuery = { __typename?: 'query_root', SessionCounter: Array<{ export const SessionIndexedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SessionIndexed"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"Session"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"children"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const SessionByMetadataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SessionByMetadata"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"deployer"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"metadataUri"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"since"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"numeric"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"Session"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"deployer"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"deployer"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"metadataUri"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"metadataUri"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"openedAt"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_gte"},"value":{"kind":"Variable","name":{"kind":"Name","value":"since"}}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"order_by"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"openedAt"},"value":{"kind":"EnumValue","value":"desc"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sessionId"}},{"kind":"Field","name":{"kind":"Name","value":"parentMarket"}},{"kind":"Field","name":{"kind":"Name","value":"transactionHash"}},{"kind":"Field","name":{"kind":"Name","value":"children"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"order_by"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"parentOutcomeIndex"},"value":{"kind":"EnumValue","value":"asc"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"parentOutcomeIndex"}},{"kind":"Field","name":{"kind":"Name","value":"transactionHash"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"chain_metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"latest_processed_block"}},{"kind":"Field","name":{"kind":"Name","value":"block_height"}}]}}]}}]} as unknown as DocumentNode; -export const SessionDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SessionDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"parentMarket"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"Session"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"parentMarket"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"parentMarket"}}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"parentMarket"}},{"kind":"Field","name":{"kind":"Name","value":"deployer"}},{"kind":"Field","name":{"kind":"Name","value":"marketName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"heroImage"}},{"kind":"Field","name":{"kind":"Name","value":"itemName"}},{"kind":"Field","name":{"kind":"Name","value":"itemNamePlural"}},{"kind":"Field","name":{"kind":"Name","value":"metadataResolved"}},{"kind":"Field","name":{"kind":"Name","value":"outcomeCount"}},{"kind":"Field","name":{"kind":"Name","value":"deployedChildCount"}},{"kind":"Field","name":{"kind":"Name","value":"invalidToken"}},{"kind":"Field","name":{"kind":"Name","value":"collateralToken"}},{"kind":"Field","name":{"kind":"Name","value":"collateralDecimals"}},{"kind":"Field","name":{"kind":"Name","value":"collateralSymbol"}},{"kind":"Field","name":{"kind":"Name","value":"openedAt"}},{"kind":"Field","name":{"kind":"Name","value":"completedAt"}},{"kind":"Field","name":{"kind":"Name","value":"openingTime"}},{"kind":"Field","name":{"kind":"Name","value":"blocks"}},{"kind":"Field","name":{"kind":"Name","value":"children"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"order_by"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"parentOutcomeIndex"},"value":{"kind":"EnumValue","value":"asc"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"parentOutcomeIndex"}},{"kind":"Field","name":{"kind":"Name","value":"parentOutcome"}},{"kind":"Field","name":{"kind":"Name","value":"marketName"}},{"kind":"Field","name":{"kind":"Name","value":"lowerBound"}},{"kind":"Field","name":{"kind":"Name","value":"upperBound"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"blocks"}},{"kind":"Field","name":{"kind":"Name","value":"upToken"}},{"kind":"Field","name":{"kind":"Name","value":"downToken"}},{"kind":"Field","name":{"kind":"Name","value":"underlyingToken"}},{"kind":"Field","name":{"kind":"Name","value":"tokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"outcomeIndex"}},{"kind":"Field","name":{"kind":"Name","value":"decimals"}},{"kind":"Field","name":{"kind":"Name","value":"symbol"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const SessionDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SessionDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"parentMarket"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"Session"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"parentMarket"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"parentMarket"}}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"parentMarket"}},{"kind":"Field","name":{"kind":"Name","value":"deployer"}},{"kind":"Field","name":{"kind":"Name","value":"marketName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"heroImage"}},{"kind":"Field","name":{"kind":"Name","value":"itemName"}},{"kind":"Field","name":{"kind":"Name","value":"itemNamePlural"}},{"kind":"Field","name":{"kind":"Name","value":"metadataResolved"}},{"kind":"Field","name":{"kind":"Name","value":"outcomeCount"}},{"kind":"Field","name":{"kind":"Name","value":"deployedChildCount"}},{"kind":"Field","name":{"kind":"Name","value":"invalidToken"}},{"kind":"Field","name":{"kind":"Name","value":"collateralToken"}},{"kind":"Field","name":{"kind":"Name","value":"collateralDecimals"}},{"kind":"Field","name":{"kind":"Name","value":"collateralSymbol"}},{"kind":"Field","name":{"kind":"Name","value":"openedAt"}},{"kind":"Field","name":{"kind":"Name","value":"completedAt"}},{"kind":"Field","name":{"kind":"Name","value":"openingTime"}},{"kind":"Field","name":{"kind":"Name","value":"blocks"}},{"kind":"Field","name":{"kind":"Name","value":"children"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"order_by"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"parentOutcomeIndex"},"value":{"kind":"EnumValue","value":"asc"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"parentOutcomeIndex"}},{"kind":"Field","name":{"kind":"Name","value":"parentOutcome"}},{"kind":"Field","name":{"kind":"Name","value":"marketName"}},{"kind":"Field","name":{"kind":"Name","value":"lowerBound"}},{"kind":"Field","name":{"kind":"Name","value":"upperBound"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"blocks"}},{"kind":"Field","name":{"kind":"Name","value":"upToken"}},{"kind":"Field","name":{"kind":"Name","value":"downToken"}},{"kind":"Field","name":{"kind":"Name","value":"underlyingToken"}}]}}]}}]}}]} as unknown as DocumentNode; export const SessionChartDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SessionChart"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sessionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"upOutcome"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"Pool"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"childMarket"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"session_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sessionId"}}}]}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"outcomeIndex"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"upOutcome"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"childMarket_id"}},{"kind":"Field","name":{"kind":"Name","value":"underlyingPerOutcome"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"swaps"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"order_by"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"timestamp"},"value":{"kind":"EnumValue","value":"asc"}}]},{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"logIndex"},"value":{"kind":"EnumValue","value":"asc"}}]}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"underlyingPerOutcomeAfter"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}}]}}]}}]}}]} as unknown as DocumentNode; export const SessionStatsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SessionStats"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sessionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"Prediction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"childMarket"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"session_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sessionId"}}}]}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"distinct_on"},"value":{"kind":"EnumValue","value":"executor_id"}},{"kind":"Argument","name":{"kind":"Name","value":"order_by"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"executor_id"},"value":{"kind":"EnumValue","value":"asc"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"executor_id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"Pool"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"childMarket"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"session_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sessionId"}}}]}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"volumeUnderlying"}}]}}]}}]} as unknown as DocumentNode; export const SessionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Sessions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"where"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Session_bool_exp"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"SessionCounter"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"Session"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"Variable","name":{"kind":"Name","value":"where"}}},{"kind":"Argument","name":{"kind":"Name","value":"order_by"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"openedAt"},"value":{"kind":"EnumValue","value":"desc"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"parentMarket"}},{"kind":"Field","name":{"kind":"Name","value":"deployer"}},{"kind":"Field","name":{"kind":"Name","value":"marketName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"heroImage"}},{"kind":"Field","name":{"kind":"Name","value":"itemNamePlural"}},{"kind":"Field","name":{"kind":"Name","value":"outcomeCount"}},{"kind":"Field","name":{"kind":"Name","value":"keyword"}},{"kind":"Field","name":{"kind":"Name","value":"openingTime"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/apps/web/src/utils/__tests__/date.test.ts b/apps/web/src/utils/__tests__/date.test.ts index c70400a3..28c5f780 100644 --- a/apps/web/src/utils/__tests__/date.test.ts +++ b/apps/web/src/utils/__tests__/date.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { fileStamp, formatUtcInstant, utcInstant } from "../date"; +import { fileStamp, formatLocalInstant, formatUtcInstant, utcInstant } from "../date"; describe("utcInstant", () => { it("composes an ISO day and HH:mm into a UTC date", () => { From fffbaa6490e4a0ef6f47b4611dfb5f5858e76d9e Mon Sep 17 00:00:00 2001 From: Harman-singh-waraich Date: Wed, 7 Oct 2026 15:27:28 +0700 Subject: [PATCH 003/621] feat(web): prediction-funcitonality --- .claude/settings.local.json.bak.20260921 | 34 +++ apps/indexer/schema.graphql | 4 + apps/indexer/src/handlers/SessionFactory.ts | 1 + .../src/utils/contracts/fetchChildMarket.ts | 8 +- .../tests/handlers/SessionFactory.test.ts | 11 +- apps/indexer/tests/support/harness.ts | 1 + apps/web/package.json | 4 +- .../[parentMarket]/_components/MarketPage.tsx | 2 +- .../_components/PredictCard.tsx | 29 +- .../_components/PredictRunner.tsx | 130 ++++++++ .../_components/__tests__/OptionCard.test.tsx | 1 + apps/web/src/components/ui/Dialog.tsx | 24 +- .../features/predict/api/getOptionPools.ts | 50 +++ .../predict/components/PredictDialog.tsx | 249 +++++++++++++++ .../__tests__/PredictDialog.test.tsx | 182 +++++++++++ .../predict/flow/__tests__/plan.test.ts | 286 ++++++++++++++++++ .../flow/__tests__/predict.fork.test.ts | 235 ++++++++++++++ .../predict/flow/__tests__/support/fakes.ts | 119 ++++++++ apps/web/src/features/predict/flow/plan.ts | 216 +++++++++++++ apps/web/src/features/predict/flow/types.ts | 68 +++++ .../features/predict/hooks/usePredictRun.ts | 122 ++++++++ apps/web/src/features/predict/run/driver.ts | 210 +++++++++++++ apps/web/src/features/predict/run/reader.ts | 72 +++++ apps/web/src/features/predict/run/types.ts | 46 +++ .../predict/utils/__tests__/plan.test.ts | 160 ++++++++++ .../predict/utils/__tests__/poolMath.test.ts | 127 ++++++++ apps/web/src/features/predict/utils/plan.ts | 133 ++++++++ .../src/features/predict/utils/poolMath.ts | 62 ++++ .../session-create/hooks/useSessionDeploy.ts | 14 +- .../src/features/sessions/api/getSession.ts | 1 + apps/web/src/features/sessions/types.ts | 2 + .../sessions/utils/__tests__/fixtures.ts | 1 + .../utils/__tests__/toSessionChart.test.ts | 1 + .../features/sessions/utils/toSessionChart.ts | 9 +- .../sessions/utils/toSessionDetail.ts | 1 + apps/web/src/lib/flowRunStore.ts | 10 + apps/web/src/lib/graphql/generated/gql.ts | 12 +- apps/web/src/lib/graphql/generated/graphql.ts | 24 +- apps/web/src/lib/swap/algebraPool.ts | 30 ++ apps/web/src/lib/swap/lens.ts | 42 +++ apps/web/src/lib/swap/types.ts | 33 ++ .../trade-executor/__tests__/predict.test.ts | 139 +++++++++ apps/web/src/lib/trade-executor/positions.ts | 50 ++- apps/web/src/lib/trade-executor/predict.ts | 72 +++++ apps/web/src/testing/fixtures.ts | 2 + apps/web/src/types/pool.ts | 7 + .../src/utils/__tests__/optionPrice.test.ts | 48 +++ apps/web/src/utils/amount.ts | 13 + apps/web/src/utils/optionPrice.ts | 36 +++ package.json | 3 +- turbo.json | 2 + yarn.lock | 13 + 52 files changed, 3095 insertions(+), 56 deletions(-) create mode 100644 .claude/settings.local.json.bak.20260921 create mode 100644 apps/web/src/app/market/[parentMarket]/_components/PredictRunner.tsx create mode 100644 apps/web/src/features/predict/api/getOptionPools.ts create mode 100644 apps/web/src/features/predict/components/PredictDialog.tsx create mode 100644 apps/web/src/features/predict/components/__tests__/PredictDialog.test.tsx create mode 100644 apps/web/src/features/predict/flow/__tests__/plan.test.ts create mode 100644 apps/web/src/features/predict/flow/__tests__/predict.fork.test.ts create mode 100644 apps/web/src/features/predict/flow/__tests__/support/fakes.ts create mode 100644 apps/web/src/features/predict/flow/plan.ts create mode 100644 apps/web/src/features/predict/flow/types.ts create mode 100644 apps/web/src/features/predict/hooks/usePredictRun.ts create mode 100644 apps/web/src/features/predict/run/driver.ts create mode 100644 apps/web/src/features/predict/run/reader.ts create mode 100644 apps/web/src/features/predict/run/types.ts create mode 100644 apps/web/src/features/predict/utils/__tests__/plan.test.ts create mode 100644 apps/web/src/features/predict/utils/__tests__/poolMath.test.ts create mode 100644 apps/web/src/features/predict/utils/plan.ts create mode 100644 apps/web/src/features/predict/utils/poolMath.ts create mode 100644 apps/web/src/lib/flowRunStore.ts create mode 100644 apps/web/src/lib/swap/algebraPool.ts create mode 100644 apps/web/src/lib/swap/lens.ts create mode 100644 apps/web/src/lib/swap/types.ts create mode 100644 apps/web/src/lib/trade-executor/__tests__/predict.test.ts create mode 100644 apps/web/src/lib/trade-executor/predict.ts create mode 100644 apps/web/src/types/pool.ts create mode 100644 apps/web/src/utils/__tests__/optionPrice.test.ts create mode 100644 apps/web/src/utils/optionPrice.ts diff --git a/.claude/settings.local.json.bak.20260921 b/.claude/settings.local.json.bak.20260921 new file mode 100644 index 00000000..ebe935c7 --- /dev/null +++ b/.claude/settings.local.json.bak.20260921 @@ -0,0 +1,34 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "allow": [ + "Read(//Users/harman/Documents/Obsidian/Workspaces/foresight/**)", + "Read(//Users/harman/Desktop/projects/**)", + "Read(//Users/harman/projects/**)", + "Read(//Users/harman/Desktop/**)", + "Edit(/.claude/skills/web3-boundary/**)", + "Bash(yarn format *)", + "Bash(yarn lint *)", + "Bash(yarn format:check)" + ], + "deny": [ + "Read(./.env)", + "Read(./.env.*)", + "Read(./**/.env)", + "Read(./**/.env.*)", + "Bash(sudo *)", + "Bash(git push --force *)", + "Bash(git push -f *)", + "Bash(git reset --hard *)" + ], + "defaultMode": "default", + "disableBypassPermissionsMode": "disable", + "additionalDirectories": [ + "/Users/harman/Documents/Obsidian/Workspaces/foresight/todos", + "/Users/harman/Documents/Obsidian/Workspaces/foresight/recaps", + "/Users/harman/Documents/Obsidian/Workspaces/foresight/docs/learnings/web3-boundary", + "/Users/harman/Documents/Obsidian/Workspaces/foresight/docs/learnings", + "/Users/harman/Documents/Obsidian/Workspaces/foresight/docs/adr" + ] + } +} diff --git a/apps/indexer/schema.graphql b/apps/indexer/schema.graphql index 0490436d..13932eb8 100644 --- a/apps/indexer/schema.graphql +++ b/apps/indexer/schema.graphql @@ -80,6 +80,10 @@ type ChildMarket { downToken: String! @index upToken: String! @index + + "burnt alongside DOWN and UP when a set is merged back; the router takes the whole partition" + invalidToken: String! + underlyingToken: String! @index keyword: String! diff --git a/apps/indexer/src/handlers/SessionFactory.ts b/apps/indexer/src/handlers/SessionFactory.ts index d151a63b..ac65efe6 100644 --- a/apps/indexer/src/handlers/SessionFactory.ts +++ b/apps/indexer/src/handlers/SessionFactory.ts @@ -144,6 +144,7 @@ indexer.onEvent({ contract: "SessionFactory", event: "ChildMarketDeployed" }, as upperBound: market.upperBound, downToken: market.down, upToken: market.up, + invalidToken: market.invalid, underlyingToken: market.underlying, displayName: display?.displayName, color: display?.color, diff --git a/apps/indexer/src/utils/contracts/fetchChildMarket.ts b/apps/indexer/src/utils/contracts/fetchChildMarket.ts index e1b94834..90b663a5 100644 --- a/apps/indexer/src/utils/contracts/fetchChildMarket.ts +++ b/apps/indexer/src/utils/contracts/fetchChildMarket.ts @@ -14,6 +14,7 @@ export const fetchChildMarket = createEffect( upperBound: S.bigint, down: S.string, up: S.string, + invalid: S.string, underlying: S.string, }), cache: true, @@ -24,13 +25,14 @@ export const fetchChildMarket = createEffect( const market = getSeerMarketContract(input.address); try { - // outcome 0 is DOWN and outcome 1 is UP - const [marketName, lowerBound, upperBound, down, up, underlying] = await Promise.all([ + // outcome 0 is DOWN and 1 is UP; Seer wraps INVALID one past the named outcomes + const [marketName, lowerBound, upperBound, down, up, invalid, underlying] = await Promise.all([ client.readContract({ ...market, functionName: "marketName" }), client.readContract({ ...market, functionName: "lowerBound" }), client.readContract({ ...market, functionName: "upperBound" }), client.readContract({ ...market, functionName: "wrappedOutcome", args: [0n] }), client.readContract({ ...market, functionName: "wrappedOutcome", args: [1n] }), + client.readContract({ ...market, functionName: "wrappedOutcome", args: [2n] }), client.readContract({ ...market, functionName: "parentWrappedOutcome" }), ]); @@ -40,6 +42,7 @@ export const fetchChildMarket = createEffect( upperBound, down: down[0].toLowerCase(), up: up[0].toLowerCase(), + invalid: invalid[0].toLowerCase(), underlying: underlying[0].toLowerCase(), }; } catch (error) { @@ -51,6 +54,7 @@ export const fetchChildMarket = createEffect( upperBound: 0n, down: UNAVAILABLE, up: UNAVAILABLE, + invalid: UNAVAILABLE, underlying: UNAVAILABLE, }; } diff --git a/apps/indexer/tests/handlers/SessionFactory.test.ts b/apps/indexer/tests/handlers/SessionFactory.test.ts index 1927bc2d..80ecf9a8 100644 --- a/apps/indexer/tests/handlers/SessionFactory.test.ts +++ b/apps/indexer/tests/handlers/SessionFactory.test.ts @@ -38,6 +38,8 @@ const PARENT_OUTCOME_0 = "0x00000000000000000000000000000000000000a1" as const; const PARENT_OUTCOME_1 = "0x00000000000000000000000000000000000000a2" as const; const CHILD_MARKET_0 = "0x0000000000000000000000000000000000000c00" as const; const CHILD_MARKET_1 = "0x0000000000000000000000000000000000000c01" as const; +const CHILD_0_INVALID = "0x0000000000000000000000000000000000000d10" as const; +const CHILD_1_INVALID = "0x0000000000000000000000000000000000000d11" as const; const DEPLOYER = "0x0000000000000000000000000000000000000de9" as const; const ESCAPING_METADATA_URI = "ipfs://../../admin/purge"; @@ -45,11 +47,11 @@ const ESCAPING_METADATA_URI = "ipfs://../../admin/purge"; /** `wrappedOutcome` and `parentWrappedOutcome` both answer (token, data). */ const wrapped = (token: string) => [token, "0x"]; -const childReads = (down: string, up: string, underlying: string) => ({ +const childReads = (down: string, up: string, underlying: string, invalid: string) => ({ marketName: CHILD_MARKET_NAME, lowerBound: LOWER_BOUND, upperBound: UPPER_BOUND, - wrappedOutcome: [wrapped(down), wrapped(up)], + wrappedOutcome: [wrapped(down), wrapped(up), wrapped(invalid)], parentWrappedOutcome: wrapped(underlying), }); @@ -63,8 +65,8 @@ const marketReads: Record> = { wrappedOutcome: [wrapped(PARENT_OUTCOME_0), wrapped(PARENT_OUTCOME_1), wrapped(PARENT_INVALID)], }, // a child on outcome i prices against the parent's wrapped outcome i - [CHILD_MARKET_0]: childReads(CHILD_0_DOWN, CHILD_0_UP, PARENT_OUTCOME_0), - [CHILD_MARKET_1]: childReads(CHILD_1_DOWN, CHILD_1_UP, PARENT_OUTCOME_1), + [CHILD_MARKET_0]: childReads(CHILD_0_DOWN, CHILD_0_UP, PARENT_OUTCOME_0, CHILD_0_INVALID), + [CHILD_MARKET_1]: childReads(CHILD_1_DOWN, CHILD_1_UP, PARENT_OUTCOME_1, CHILD_1_INVALID), [CHILD_0_DOWN]: { decimals: 18, symbol: "DUNE0-DOWN" }, [CHILD_0_UP]: { decimals: 18, symbol: "DUNE0-UP" }, [PARENT_OUTCOME_0]: { decimals: 18, symbol: "VILLENEUVE" }, @@ -224,6 +226,7 @@ describe("SessionFactory indexing", () => { const child = await indexer.ChildMarket.getOrThrow(CHILD_MARKET_0); t.expect(child.session_id).toBe("0"); + t.expect(child.invalidToken).toBe(CHILD_0_INVALID); t.expect(child.parentOutcomeIndex).toBe(0n); t.expect(child.parentOutcome).toBe("Villeneuve"); t.expect(child.marketName).toBe(CHILD_MARKET_NAME); diff --git a/apps/indexer/tests/support/harness.ts b/apps/indexer/tests/support/harness.ts index fb07691e..386fbd95 100644 --- a/apps/indexer/tests/support/harness.ts +++ b/apps/indexer/tests/support/harness.ts @@ -113,6 +113,7 @@ export const seedMarket = async (indexer: TestIndexer, market: SeededMarket) => upperBound: market.upperBound, downToken: market.down, upToken: market.up, + invalidToken: `${market.id}-invalid`, underlyingToken: market.underlying, displayName: undefined, color: undefined, diff --git a/apps/web/package.json b/apps/web/package.json index 38da48cf..db75001b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,7 +15,8 @@ "codegen": "graphql-codegen", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:fork": "FORK_RPC_URL=${FORK_RPC_URL:-http://127.0.0.1:8545} node --env-file-if-exists=.env.local --env-file=.env.gnosis-fork \"$(yarn bin vitest)\" run predict.fork" }, "dependencies": { "@foresight/contracts": "workspace:*", @@ -28,6 +29,7 @@ "@kleros/ui-components-library": "^3.8.0", "@reown/appkit": "^1.8.22", "@reown/appkit-adapter-wagmi": "^1.8.22", + "@seer-pm/lens": "1.5.1", "@tailwindcss/postcss": "^4.3.3", "@tanstack/react-query": "^5.101.3", "@wagmi/core": "^3.6.3", diff --git a/apps/web/src/app/market/[parentMarket]/_components/MarketPage.tsx b/apps/web/src/app/market/[parentMarket]/_components/MarketPage.tsx index 311d2535..4d1b7f43 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/MarketPage.tsx +++ b/apps/web/src/app/market/[parentMarket]/_components/MarketPage.tsx @@ -104,7 +104,7 @@ export function MarketPage({ parentMarket }: { parentMarket: string }) { marketEstimates={marketEstimates} noun={noun} /> - +
diff --git a/apps/web/src/app/market/[parentMarket]/_components/PredictCard.tsx b/apps/web/src/app/market/[parentMarket]/_components/PredictCard.tsx index 44d06bdd..eab89e72 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/PredictCard.tsx +++ b/apps/web/src/app/market/[parentMarket]/_components/PredictCard.tsx @@ -1,9 +1,32 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useState } from "react"; + import { Button } from "@kleros/ui-components-library"; import clsx from "clsx"; +import { useSessionEstimates } from "@/features/predict/stores/estimateStore"; +import { selectedEstimates } from "@/features/predict/utils/estimates"; +import type { SessionDetail } from "@/features/sessions/types"; + import { SectionCard } from "@/components/ui/SectionCard"; -export function PredictCard() { +// The run pulls in the orchestrator, the quoter and a chain client; none of that belongs on the +// page until someone asks to predict. +const PredictRunner = dynamic(() => import("./PredictRunner").then((module) => module.PredictRunner), { ssr: false }); + +interface PredictCardProps { + session: SessionDetail; + marketEstimates: Record; +} + +export function PredictCard({ session, marketEstimates }: PredictCardProps) { + const [isOpen, setIsOpen] = useState(false); + + const estimates = useSessionEstimates(session.parentMarket); + const selected = selectedEstimates(session.options, estimates, marketEstimates); + return (

Predict all the estimates above

- + + , + ); + + const panel = layerOf(screen.getByRole("complementary", { name: "Development wallet", hidden: true })); + const modal = layerOf(screen.getByRole("dialog", { name: "Predict" })); + + expect(panel).toBeLessThan(modal); + }); +}); From e2d9fe4f0bebe440baab6697980b746ebe2f9f68 Mon Sep 17 00:00:00 2001 From: Harman-singh-waraich Date: Wed, 14 Oct 2026 11:57:25 +0700 Subject: [PATCH 105/621] fix(web): keep the dev wallet panel under open modals The panel drops from z-50 to z-15, below the Kleros modal overlay (z-20) and the header (z-30), so at 375 px it no longer covers the predict popup's Cancel button. The panel renders only in dev builds; production layout is unchanged. Issue: 2026-10-08-foresight-predict-popup-layout-mobile Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/wallet/DevWallet.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/wallet/DevWallet.tsx b/apps/web/src/components/wallet/DevWallet.tsx index 93d52e87..830fc9c5 100644 --- a/apps/web/src/components/wallet/DevWallet.tsx +++ b/apps/web/src/components/wallet/DevWallet.tsx @@ -40,8 +40,9 @@ export function DevWallet() { }; return ( + // z-15 keeps it under the Kleros modal overlay (z-20) and the header (z-30).