diff --git a/.storybook/main.ts b/.storybook/main.ts index 391e0f67..4dece480 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -22,6 +22,18 @@ const config: StorybookConfig = { // Ensure proper alias resolution config.resolve = config.resolve || {}; config.resolve.alias = { + // Mock DB-coupled server actions so components that import them can render + // in Storybook without bundling the Postgres client or next/cache. The + // more specific subpath must be listed before the barrel. + "@/lib/db_actions/props": new URL( + "./mocks/db_actions-props.ts", + import.meta.url, + ).pathname, + "@/lib/db_actions": new URL("./mocks/db_actions.ts", import.meta.url) + .pathname, + // next/link's app-router internals trip a circular-import TDZ under Vite + // dev; render a plain anchor instead. + "next/link": new URL("./mocks/next-link.tsx", import.meta.url).pathname, ...config.resolve.alias, "@": new URL("../", import.meta.url).pathname, }; diff --git a/.storybook/mocks/db_actions-props.ts b/.storybook/mocks/db_actions-props.ts new file mode 100644 index 00000000..ab8df180 --- /dev/null +++ b/.storybook/mocks/db_actions-props.ts @@ -0,0 +1,4 @@ +// Storybook mock for `@/lib/db_actions/props` (used by PropEditDialog). +import { success } from "@/lib/server-action-result"; + +export const updateProp = async () => success(undefined); diff --git a/.storybook/mocks/db_actions.ts b/.storybook/mocks/db_actions.ts new file mode 100644 index 00000000..48585c20 --- /dev/null +++ b/.storybook/mocks/db_actions.ts @@ -0,0 +1,6 @@ +// Storybook mock for `@/lib/db_actions`. Stories never touch the database; +// these stubs just return a success result so server-action hooks resolve. +import { success } from "@/lib/server-action-result"; + +export const createForecast = async () => success(undefined); +export const updateForecast = async () => success(undefined); diff --git a/.storybook/mocks/next-link.tsx b/.storybook/mocks/next-link.tsx new file mode 100644 index 00000000..161d4c4e --- /dev/null +++ b/.storybook/mocks/next-link.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; + +/** + * Storybook stand-in for `next/link`: render a plain anchor. + * + * The real module pulls in Next's app-router client internals, whose circular + * ESM imports trip a "Cannot access 'default' before initialization" TDZ error + * under Vite's on-demand dev transform (production rollup hoists around it). + */ +type LinkProps = React.PropsWithChildren< + React.AnchorHTMLAttributes & { + href: string | { pathname?: string }; + } +>; + +const Link = React.forwardRef(function Link( + { href, children, ...props }, + ref, +) { + const hrefStr = typeof href === "string" ? href : (href?.pathname ?? "#"); + return ( + + {children} + + ); +}); + +export default Link; diff --git a/components/forecast-card/editable-forecast-card.stories.tsx b/components/forecast-card/editable-forecast-card.stories.tsx new file mode 100644 index 00000000..72b55649 --- /dev/null +++ b/components/forecast-card/editable-forecast-card.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { EditableForecastCard } from "./editable-forecast-card"; +import { makeProp } from "./forecast-card.fixtures"; + +// Note: in Storybook there's no logged-in user (the /api/me fetch returns +// null), so the admin "edit prop" pencil is hidden. The Save / Cancel buttons +// only appear once you change the forecast (type a new value in the % box). +// Saving is mocked — see .storybook/mocks/db_actions.ts. +const meta = { + title: "Forecast/EditableForecastCard", + component: EditableForecastCard, + parameters: { + layout: "padded", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + argTypes: { + prop: { + control: false, + description: "The prop plus the user's forecast and community average", + }, + onForecastUpdate: { + control: false, + description: "Called after a forecast is created or updated", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Has a forecast: needle (with community-average ghost) and the editable % box. +export const Default: Story = { + args: { + prop: makeProp({ user_forecast: 0.6, user_forecast_id: 10 }), + }, +}; + +// No forecast yet: placeholder + empty % box; type a value to set it. +export const NoForecast: Story = { + args: { + prop: makeProp({ user_forecast: null, user_forecast_id: null }), + }, +}; + +// Low probability -> needle points left, into the red. +export const LowProbability: Story = { + args: { + prop: makeProp({ user_forecast: 0.12, user_forecast_id: 10 }), + }, +}; + +// High probability -> needle points right, into the green. +export const HighProbability: Story = { + args: { + prop: makeProp({ user_forecast: 0.91, user_forecast_id: 10 }), + }, +}; + +// Long prop text wraps; the edit pencil stays beside it. +export const LongPropText: Story = { + args: { + prop: makeProp({ + user_forecast: 0.45, + user_forecast_id: 10, + prop_text: + "Will at least three of the five largest economies report **negative** quarterly GDP growth in the same quarter before the end of the year?", + prop_notes: + "Measured by nominal GDP; figures from each country's official statistics agency.", + }), + }, +}; + +// Long notes wrap rather than spilling off the side. +export const LongNotes: Story = { + args: { + prop: makeProp({ + user_forecast: 0.6, + user_forecast_id: 10, + prop_notes: + "Resolution uses the seasonally adjusted figures published by each country's primary national statistics office; preliminary estimates count, and later revisions will not change a resolution once it has been finalized by the competition admins.", + }), + }, +}; diff --git a/components/forecast-card/editable-forecast-card.tsx b/components/forecast-card/editable-forecast-card.tsx index 1864bbf0..0ca94cf1 100644 --- a/components/forecast-card/editable-forecast-card.tsx +++ b/components/forecast-card/editable-forecast-card.tsx @@ -1,72 +1,72 @@ "use client"; -import { useState, useRef } from "react"; +import { useState } from "react"; import { PropWithUserForecast } from "@/types/db_types"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Pencil } from "lucide-react"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { MarkdownRenderer } from "@/components/markdown"; +import { ForecastNeedle } from "@/components/ui/forecast-needle"; import { useCurrentUser } from "@/hooks/useCurrentUser"; import { createForecast, updateForecast } from "@/lib/db_actions"; import { useServerAction } from "@/hooks/use-server-action"; import { PropEditDialog } from "@/components/dialogs/prop-edit-dialog"; import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; interface EditableForecastCardProps { prop: PropWithUserForecast; onForecastUpdate?: () => void; } -// Helper to get color based on probability -const getProbColor = (prob: number | null) => { - if (prob === null) - return { - bg: "bg-muted", - text: "text-muted-foreground", - bar: "bg-muted-foreground/30", - border: "border-muted-foreground/30", - }; - if (prob <= 0.2) - return { - bg: "bg-red-100", - text: "text-red-700", - bar: "bg-red-400", - border: "border-red-400", - }; - if (prob <= 0.4) - return { - bg: "bg-orange-100", - text: "text-orange-700", - bar: "bg-orange-400", - border: "border-orange-400", - }; - if (prob <= 0.6) - return { - bg: "bg-yellow-100", - text: "text-yellow-700", - bar: "bg-yellow-500", - border: "border-yellow-500", - }; - if (prob <= 0.8) - return { - bg: "bg-lime-100", - text: "text-lime-700", - bar: "bg-lime-500", - border: "border-lime-500", - }; - return { - bg: "bg-green-100", - text: "text-green-700", - bar: "bg-green-500", - border: "border-green-500", +// Raw number entry for the forecast percentage (0–100). Commits on Enter/blur, +// Escape reverts; while unfocused it mirrors the current value. +function PercentInput({ + value, + onChange, +}: { + value: number | null; + onChange: (v: number) => void; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(""); + const formatted = value == null ? "" : String(Math.round(value * 100)); + const display = editing ? draft : formatted; + + const commit = () => { + setEditing(false); + const n = Number(draft); + if (draft.trim() === "" || Number.isNaN(n)) return; + onChange(Math.max(0, Math.min(100, Math.round(n))) / 100); }; -}; + + return ( + + ); +} export function EditableForecastCard({ prop, @@ -76,7 +76,6 @@ export function EditableForecastCard({ const [localForecast, setLocalForecast] = useState( prop.user_forecast, ); - const [isDragging, setIsDragging] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const createForecastAction = useServerAction(createForecast, { @@ -96,61 +95,8 @@ export function EditableForecastCard({ const isSubmitting = createForecastAction.isLoading || updateForecastAction.isLoading; - const [isEditingPercent, setIsEditingPercent] = useState(false); - const [percentInputValue, setPercentInputValue] = useState(""); - const percentInputRef = useRef(null); - const hasChanges = localForecast !== prop.user_forecast; - const colors = getProbColor(localForecast); - const percent = - localForecast !== null ? Math.round(localForecast * 100) : null; - - const handlePercentClick = () => { - setPercentInputValue(percent !== null ? String(percent) : ""); - setIsEditingPercent(true); - setTimeout(() => percentInputRef.current?.select(), 0); - }; - - const commitPercentInput = () => { - setIsEditingPercent(false); - const trimmed = percentInputValue.trim(); - if (trimmed === "") return; - const num = Number(trimmed); - if (isNaN(num) || num < 0 || num > 100) return; - setLocalForecast(Math.round(num) / 100); - }; - - const handlePercentKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - commitPercentInput(); - } else if (e.key === "Escape") { - setIsEditingPercent(false); - } - }; - - const handleBarClick = (e: React.MouseEvent) => { - const rect = e.currentTarget.getBoundingClientRect(); - const x = e.clientX - rect.left; - const newValue = Math.max(0, Math.min(1, x / rect.width)); - setLocalForecast(Math.round(newValue * 100) / 100); - }; - - const handleMouseDown = (e: React.MouseEvent) => { - setIsDragging(true); - handleBarClick(e); - }; - - const handleMouseMove = (e: React.MouseEvent) => { - if (!isDragging) return; - const rect = e.currentTarget.getBoundingClientRect(); - const x = e.clientX - rect.left; - const newValue = Math.max(0, Math.min(1, x / rect.width)); - setLocalForecast(Math.round(newValue * 100) / 100); - }; - - const handleMouseUp = () => { - setIsDragging(false); - }; + const baseline = prop.community_average ?? undefined; const handleSave = async () => { if (!user || localForecast === null) return; @@ -177,136 +123,44 @@ export function EditableForecastCard({ return (
- {/* Probability box */} -
- {isEditingPercent ? ( -
- setPercentInputValue(e.target.value)} - onBlur={commitPercentInput} - onKeyDown={handlePercentKeyDown} - className="w-12 text-2xl font-bold text-center bg-transparent outline-none border-b-2 border-current" - aria-label="Forecast percentage" - /> - % -
- ) : ( -
- {percent !== null ? `${percent}%` : "—"} -
- )} -
+
+
+ + {prop.category_name} + +
-
-
-
- - {prop.category_name} - +
+
+

+ {prop.prop_text} +

+

+ {prop.prop_notes || " "} +

{user?.is_admin && ( - + + )}
- - - -
-

- {prop.prop_text} -

-

- {prop.prop_notes || "\u00A0"} -

-
-
- -
-

{prop.prop_text}

- {prop.prop_notes && ( -

- {prop.prop_notes} -

- )} -
-
-
-
- - {/* Slider bar */} -
-
- {/* Filled portion */} - {percent !== null && ( -
- )} - - {/* Draggable handle */} - {percent !== null && ( -
- )} - - {/* Click hint for empty state */} - {percent === null && ( -
- - Click to set forecast - -
- )} -
- - {/* Scale labels */} -
- 0% - 50% - 100% -
-
- - {/* Action buttons when there are changes */} {hasChanges && ( -
+
+ + {/* Forecast needle (with community-average ghost) + number entry */} +
+ {localForecast != null ? ( + <> + + + {baseline != null && ( +
+ Average: {Math.round(baseline * 100)}% +
+ )} + + ) : ( + <> +
+ No forecast yet +
+ + + )} +
{user?.is_admin && ( diff --git a/components/forecast-card/forecast-card.fixtures.ts b/components/forecast-card/forecast-card.fixtures.ts new file mode 100644 index 00000000..f3935055 --- /dev/null +++ b/components/forecast-card/forecast-card.fixtures.ts @@ -0,0 +1,35 @@ +import type { PropWithUserForecast } from "@/types/db_types"; + +// Shared mock data for the forecast-card stories. +// Fixed dates so prop status (open / closed / resolved) is deterministic. +export const FUTURE = new Date("2030-06-01T00:00:00Z"); +export const PAST = new Date("2020-01-01T00:00:00Z"); + +export function makeProp( + overrides: Partial = {}, +): PropWithUserForecast { + return { + prop_id: 1, + prop_text: "Will the temperature exceed 30°C tomorrow?", + prop_notes: "Based on the local weather station's midday reading.", + prop_user_id: null, + prop_forecasts_due_date: FUTURE, + prop_resolution_due_date: FUTURE, + prop_created_by_user_id: null, + category_id: 1, + category_name: "Weather", + competition_id: 1, + competition_name: "2026 Predictions", + competition_is_private: false, + competition_forecasts_close_date: FUTURE, + competition_forecasts_open_date: PAST, + resolution_id: null, + resolution: null, + resolution_user_id: null, + resolution_notes: null, + user_forecast: 0.72, + user_forecast_id: 10, + community_average: 0.58, + ...overrides, + }; +} diff --git a/components/forecast-card/forecast-card.stories.tsx b/components/forecast-card/forecast-card.stories.tsx new file mode 100644 index 00000000..efb86621 --- /dev/null +++ b/components/forecast-card/forecast-card.stories.tsx @@ -0,0 +1,127 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ForecastCard } from "./forecast-card"; +import { makeProp, PAST } from "./forecast-card.fixtures"; + +const meta = { + title: "Forecast/ForecastCard", + component: ForecastCard, + parameters: { + layout: "padded", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + argTypes: { + showCommunityAvg: { + control: "boolean", + description: + "Whether to show the community average (baseline ghost needle + readout)", + }, + prop: { + control: false, + description: "The prop plus the user's forecast and community average", + }, + }, + args: { + showCommunityAvg: true, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// The user has forecasted; community average shown for comparison. +export const Default: Story = { + args: { + prop: makeProp(), + showCommunityAvg: true, + }, +}; + +// Same card with the community-average comparison hidden. +export const WithoutCommunityAverage: Story = { + args: { + prop: makeProp(), + showCommunityAvg: false, + }, +}; + +// The user hasn't forecasted yet -> "No forecast yet" placeholder. +export const NoForecast: Story = { + args: { + prop: makeProp({ user_forecast: null, user_forecast_id: null }), + showCommunityAvg: true, + }, +}; + +// Low probability -> needle points left, into the red. +export const LowProbability: Story = { + args: { + prop: makeProp({ user_forecast: 0.12, community_average: 0.25 }), + showCommunityAvg: true, + }, +}; + +// High probability -> needle points right, into the green. +export const HighProbability: Story = { + args: { + prop: makeProp({ user_forecast: 0.91, community_average: 0.8 }), + showCommunityAvg: true, + }, +}; + +// Forecast equals the average -> the baseline ghost needle sits behind yours. +export const ForecastEqualsAverage: Story = { + args: { + prop: makeProp({ user_forecast: 0.6, community_average: 0.6 }), + showCommunityAvg: true, + }, +}; + +// Past the forecast deadline -> "Closed" status badge. +export const Closed: Story = { + args: { + prop: makeProp({ competition_forecasts_close_date: PAST }), + showCommunityAvg: true, + }, +}; + +// Resolved yes -> "Yes" status badge. +export const ResolvedYes: Story = { + args: { + prop: makeProp({ + competition_forecasts_close_date: PAST, + resolution: true, + resolution_id: 5, + user_forecast: 0.8, + community_average: 0.74, + }), + showCommunityAvg: true, + }, +}; + +// No notes -> the notes line collapses to a blank spacer. +export const WithoutNotes: Story = { + args: { + prop: makeProp({ prop_notes: null }), + showCommunityAvg: true, + }, +}; + +// Long prop text wraps; markdown is rendered. +export const LongPropText: Story = { + args: { + prop: makeProp({ + prop_text: + "Will at least three of the five largest economies report **negative** quarterly GDP growth in the same quarter before the end of the year?", + prop_notes: + "Measured by nominal GDP; figures taken from each country's official statistics agency.", + }), + showCommunityAvg: true, + }, +}; diff --git a/components/forecast-card/forecast-card.tsx b/components/forecast-card/forecast-card.tsx index 479f3a13..2e6420d2 100644 --- a/components/forecast-card/forecast-card.tsx +++ b/components/forecast-card/forecast-card.tsx @@ -2,17 +2,10 @@ import { PropWithUserForecast } from "@/types/db_types"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { PropStatusBadge } from "@/components/ui/prop-status-badge"; import { getPropStatusFromProp } from "@/lib/prop-status"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { MarkdownRenderer } from "@/components/markdown"; -import { ExternalLink } from "lucide-react"; +import { ForecastNeedle } from "@/components/ui/forecast-needle"; import Link from "next/link"; interface ForecastCardProps { @@ -20,218 +13,59 @@ interface ForecastCardProps { showCommunityAvg: boolean; } -// Helper to get color based on probability -const getProbColor = (prob: number) => { - if (prob <= 0.2) - return { bg: "bg-red-100", text: "text-red-700", bar: "bg-red-400" }; - if (prob <= 0.4) - return { - bg: "bg-orange-100", - text: "text-orange-700", - bar: "bg-orange-400", - }; - if (prob <= 0.6) - return { - bg: "bg-yellow-100", - text: "text-yellow-700", - bar: "bg-yellow-500", - }; - if (prob <= 0.8) - return { bg: "bg-lime-100", text: "text-lime-700", bar: "bg-lime-500" }; - return { bg: "bg-green-100", text: "text-green-700", bar: "bg-green-500" }; -}; - export function ForecastCard({ prop, showCommunityAvg }: ForecastCardProps) { - const userForecast = prop.user_forecast; - const communityAvg = showCommunityAvg ? prop.community_average : null; - - // If user hasn't forecasted, show a placeholder - if (userForecast === null) { - return ( -
-
-
-
--
-
-
-
-
- - {prop.category_name} - - -
- - - -
- - - -
-

- {prop.prop_text} -

-

- {prop.prop_notes || "\u00A0"} -

-
-
- -
-

{prop.prop_text}

- {prop.prop_notes && ( -

- {prop.prop_notes} -

- )} -
-
-
-
-
-
-
- ); - } - - const colors = getProbColor(userForecast); - const percent = Math.round(userForecast * 100); - const hasCommunityAvg = communityAvg !== null; - const communityPercent = hasCommunityAvg - ? Math.round(communityAvg * 100) - : null; - - // Check if labels would collide (within ~12% of each other) - const tooClose = - hasCommunityAvg && - communityPercent !== null && - Math.abs(percent - communityPercent) < 12; - const identical = hasCommunityAvg && percent === communityPercent; - const youOnLeft = - hasCommunityAvg && communityPercent !== null && percent < communityPercent; + const forecast = prop.user_forecast; + const baseline = + showCommunityAvg && prop.community_average != null + ? prop.community_average + : undefined; return ( -
+
- {/* Fixed-width probability box - stretches to match content height */} -
-
{percent}%
-
- -
-
-
- - {prop.category_name} - - -
- - - +
+
+ + {prop.category_name} + +
+

+ {prop.prop_text} +

+

+ {prop.prop_notes || " "} +

+
- - - -
-

- {prop.prop_text} -

-

- {prop.prop_notes || "\u00A0"} -

-
-
- -
-

{prop.prop_text}

- {prop.prop_notes && ( -

- {prop.prop_notes} -

- )} -
-
-
-
- - {/* Comparison bar with labels below - only show if community avg exists */} - {hasCommunityAvg && communityPercent !== null && ( -
- {/* The bar */} -
-
-
- - {/* Labels and ticks below the bar */} -
- {identical ? ( - /* When identical, show combined label */ -
-
- - you / avg - -
- ) : ( - <> - {/* "you" marker */} -
-
- - you - -
- - {/* "avg" marker */} -
-
- avg -
- - )} + {/* Forecast needle: the user's forecast, with the community average as + a muted "ghost" needle and a small readout below. */} +
+ {forecast != null ? ( + <> + +
+ {Math.round(forecast * 100)}%
-
+ {baseline != null && ( +
+ Average: {Math.round(baseline * 100)}% +
+ )} + + ) : ( +
No forecast yet
)}
-
+ ); } diff --git a/components/ui/forecast-needle.stories.tsx b/components/ui/forecast-needle.stories.tsx new file mode 100644 index 00000000..9152a430 --- /dev/null +++ b/components/ui/forecast-needle.stories.tsx @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ForecastNeedle } from "./forecast-needle"; + +const meta = { + title: "UI/ForecastNeedle", + component: ForecastNeedle, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + forecast: { + control: { type: "range", min: 0, max: 1, step: 0.01 }, + description: "The user's forecast (0–1), drawn in the primary color", + }, + baseline: { + control: { type: "range", min: 0, max: 1, step: 0.01 }, + description: "Optional baseline, e.g. community average (0–1), drawn muted", + }, + forecastLabel: { + control: "text", + description: 'Tooltip label for the forecast (default "You")', + }, + baselineLabel: { + control: "text", + description: 'Tooltip label for the baseline (default "Avg")', + }, + size: { + control: "select", + options: ["sm", "md", "lg"], + }, + showAxisLabels: { + control: "boolean", + }, + }, + args: { + forecast: 0.72, + size: "md", + showAxisLabels: true, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// (a) The user's forecast on its own. +export const UserForecast: Story = { + args: { + forecast: 0.72, + }, +}; + +// (b) The user's forecast plus a muted baseline (e.g. the community average). +export const WithBaseline: Story = { + args: { + forecast: 0.72, + baseline: 0.55, + }, +}; + +// Sanity check across the arc: low / mid / high. +export const LowMidHigh: Story = { + render: (args) => ( +
+ + + +
+ ), +}; + +export const Sizes: Story = { + render: (args) => ( +
+ + + +
+ ), +}; + +export const CustomLabels: Story = { + args: { + forecast: 0.66, + baseline: 0.4, + forecastLabel: "Me", + baselineLabel: "Community", + }, +}; + +export const NoAxisLabels: Story = { + args: { + forecast: 0.68, + baseline: 0.5, + showAxisLabels: false, + }, +}; + +// Approximates how it might look inside a forecast card. +export const InContext: Story = { + render: (args) => ( +
+
Geopolitics
+

+ Will the temperature exceed 30°C tomorrow? +

+
+ +
+
+ ), +}; diff --git a/components/ui/forecast-needle.test.ts b/components/ui/forecast-needle.test.ts new file mode 100644 index 00000000..9fb9ed1e --- /dev/null +++ b/components/ui/forecast-needle.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + SWEEP_DEGREES, + clamp01, + valueToAngle, + valueToRotation, + valueToPoint, +} from "./forecast-needle"; + +const HALF_SWEEP = SWEEP_DEGREES / 2; +const toRad = (deg: number) => (deg * Math.PI) / 180; + +describe("clamp01", () => { + it("passes through values in range", () => { + expect(clamp01(0)).toBe(0); + expect(clamp01(0.37)).toBe(0.37); + expect(clamp01(1)).toBe(1); + }); + + it("clamps out-of-range values", () => { + expect(clamp01(-0.5)).toBe(0); + expect(clamp01(1.5)).toBe(1); + }); + + it("treats NaN as 0", () => { + expect(clamp01(Number.NaN)).toBe(0); + }); +}); + +describe("valueToAngle", () => { + it("centers the arc on straight-up and spans SWEEP_DEGREES", () => { + expect(valueToAngle(0)).toBeCloseTo(90 + HALF_SWEEP); + expect(valueToAngle(0.5)).toBeCloseTo(90); + expect(valueToAngle(1)).toBeCloseTo(90 - HALF_SWEEP); + }); + + it("is monotonically decreasing and symmetric about 0.5", () => { + expect(valueToAngle(0.25)).toBeGreaterThan(valueToAngle(0.75)); + expect(valueToAngle(0.25) - 90).toBeCloseTo(90 - valueToAngle(0.75)); + }); + + it("clamps before mapping", () => { + expect(valueToAngle(-1)).toBeCloseTo(90 + HALF_SWEEP); + expect(valueToAngle(2)).toBeCloseTo(90 - HALF_SWEEP); + }); +}); + +describe("valueToRotation", () => { + it("maps 0 -> -SWEEP/2, 0.5 -> 0, 1 -> +SWEEP/2", () => { + expect(valueToRotation(0)).toBeCloseTo(-HALF_SWEEP); + expect(valueToRotation(0.5)).toBeCloseTo(0); + expect(valueToRotation(1)).toBeCloseTo(HALF_SWEEP); + }); + + it("is the complement of the math angle (90 - angle)", () => { + for (const v of [0, 0.2, 0.5, 0.8, 1]) { + expect(valueToRotation(v)).toBeCloseTo(90 - valueToAngle(v)); + } + }); +}); + +describe("valueToPoint", () => { + const cx = 88; + const cy = 90; + const r = 76; + + it("places value 0.5 straight above the center", () => { + const p = valueToPoint(0.5, cx, cy, r); + expect(p.x).toBeCloseTo(cx); + expect(p.y).toBeCloseTo(cy - r); + }); + + it("lifts the ends symmetrically off the baseline", () => { + const left = valueToPoint(0, cx, cy, r); + const right = valueToPoint(1, cx, cy, r); + + // Symmetric about the vertical center line. + expect(left.x).toBeCloseTo(cx - r * Math.sin(toRad(HALF_SWEEP))); + expect(right.x).toBeCloseTo(cx + r * Math.sin(toRad(HALF_SWEEP))); + expect(left.x + right.x).toBeCloseTo(2 * cx); + + // Ends sit above the hub (not on the baseline like a flat semicircle). + expect(left.y).toBeCloseTo(cy - r * Math.cos(toRad(HALF_SWEEP))); + expect(left.y).toBeCloseTo(right.y); + expect(left.y).toBeLessThan(cy); + expect(left.y).toBeGreaterThan(cy - r); + }); + + it("keeps tips above the center line for in-range values", () => { + for (const v of [0.1, 0.3, 0.6, 0.9]) { + expect(valueToPoint(v, cx, cy, r).y).toBeLessThan(cy); + } + }); +}); diff --git a/components/ui/forecast-needle.tsx b/components/ui/forecast-needle.tsx new file mode 100644 index 00000000..8f8f41e6 --- /dev/null +++ b/components/ui/forecast-needle.tsx @@ -0,0 +1,337 @@ +"use client"; + +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +export interface ForecastNeedleProps + extends Omit, "color"> { + /** The user's forecast in [0, 1]. Always drawn in the primary color. */ + forecast: number; + /** + * Optional baseline to compare against (e.g. the community average) in + * [0, 1]. When provided, it's drawn behind the forecast as a muted needle. + */ + baseline?: number; + /** Tooltip label for the user's forecast. Defaults to "You". */ + forecastLabel?: string; + /** Tooltip label for the baseline. Defaults to "Avg". */ + baselineLabel?: string; + size?: "sm" | "md" | "lg"; + /** Show the 0% / 100% axis labels. Defaults to true. */ + showAxisLabels?: boolean; +} + +/** Internal representation of one drawn needle. */ +type Needle = { + value: number; + label: string; + fill: string; + stroke: string; + strokeWidth: number; +}; + +// --------------------------------------------------------------------------- +// Geometry (pure, exported for testing) +// --------------------------------------------------------------------------- + +/** + * Total angular sweep of the gauge, in degrees. 180 would be a flat + * semicircle; smaller values lift the ends off the baseline, making the gauge + * taller and narrower. + */ +export const SWEEP_DEGREES = 140; + +export function clamp01(value: number): number { + if (Number.isNaN(value)) return 0; + return Math.min(1, Math.max(0, value)); +} + +/** + * Math angle in degrees, measured counter-clockwise from the +x axis. The arc + * is centered on straight-up (90deg) and spans `SWEEP_DEGREES`: + * value 0 -> 90 + SWEEP/2 (upper-left end) + * value 0.5 -> 90 (straight up) + * value 1 -> 90 - SWEEP/2 (upper-right end) + */ +export function valueToAngle(value: number): number { + return 90 + SWEEP_DEGREES / 2 - clamp01(value) * SWEEP_DEGREES; +} + +/** + * Clockwise SVG rotation (degrees) for a needle drawn pointing straight up at + * value 0.5: value 0 -> -SWEEP/2, value 0.5 -> 0, value 1 -> +SWEEP/2. + */ +export function valueToRotation(value: number): number { + return (clamp01(value) - 0.5) * SWEEP_DEGREES; +} + +/** Point on a circle of `radius` around (cx, cy) at the value's angle, in SVG (y-down) coords. */ +export function valueToPoint( + value: number, + cx: number, + cy: number, + radius: number, +): { x: number; y: number } { + const radians = (valueToAngle(value) * Math.PI) / 180; + return { x: cx + radius * Math.cos(radians), y: cy - radius * Math.sin(radians) }; +} + +// --------------------------------------------------------------------------- +// Drawing constants (in viewBox user units) +// --------------------------------------------------------------------------- + +const VB_W = 176; +const VB_H = 102; +const CX = 88; +const CY = 90; +const ARC_R = 76; // centerline radius of the band +const ARC_STROKE = 18; // band thickness +const ARC_OUTLINE_WIDTH = 3; // dark ink outline around the band +const NEEDLE_R = 60; +const NEEDLE_HALF_BASE = 5; +const NEEDLE_SHOULDER = 12; // distance from the tip where the needle starts to taper +const HUB_R = 7; +const OUTLINE = "var(--foreground)"; +const OUTLINE_WIDTH = 2; + +// The user's forecast is a solid, ink-outlined primary needle; the baseline is +// a lighter, outline-only "ghost" needle in a muted gray. +const FORECAST_COLOR = "var(--primary)"; +const BASELINE_COLOR = "var(--muted-foreground)"; +const BASELINE_OUTLINE_WIDTH = 2.5; + +// Cartoony red -> white -> green likelihood gradient. +const GRADIENT_STOPS = [ + { offset: "0%", color: "var(--color-red-500)" }, + { offset: "50%", color: "#ffffff" }, + { offset: "100%", color: "var(--color-green-500)" }, +]; + +// The band is a circular ring sliced by a horizontal line, so its two ends are +// flat and parallel to the x-axis. The slice sits at the height of the sweep's +// end on the centerline, keeping the arch aligned with the 0% / 100% needles. +const ARC_OUTER_R = ARC_R + ARC_STROKE / 2; +const ARC_INNER_R = ARC_R - ARC_STROKE / 2; +const ARC_START = valueToPoint(0, CX, CY, ARC_R); +const ARC_END = valueToPoint(1, CX, CY, ARC_R); +const ARC_BASE_Y = ARC_START.y; // flat bottom height +const ARC_BASE_DROP = CY - ARC_BASE_Y; +const ARC_OUTER_HALF_W = Math.sqrt(ARC_OUTER_R ** 2 - ARC_BASE_DROP ** 2); +const ARC_INNER_HALF_W = Math.sqrt(ARC_INNER_R ** 2 - ARC_BASE_DROP ** 2); +// Outer arc over the top, flat slice down to the inner edge, inner arc back, +// flat slice closing the other end. +const ARC_PATH = [ + `M ${CX - ARC_OUTER_HALF_W} ${ARC_BASE_Y}`, + `A ${ARC_OUTER_R} ${ARC_OUTER_R} 0 0 1 ${CX + ARC_OUTER_HALF_W} ${ARC_BASE_Y}`, + `L ${CX + ARC_INNER_HALF_W} ${ARC_BASE_Y}`, + `A ${ARC_INNER_R} ${ARC_INNER_R} 0 0 0 ${CX - ARC_INNER_HALF_W} ${ARC_BASE_Y}`, + "Z", +].join(" "); +const NEEDLE_TIP_Y = CY - NEEDLE_R; +const NEEDLE_SHOULDER_Y = NEEDLE_TIP_Y + NEEDLE_SHOULDER; +// Blocky "pencil" needle: a straight-sided bar that tapers to a point. +const NEEDLE_POINTS = [ + `${CX - NEEDLE_HALF_BASE},${CY}`, + `${CX - NEEDLE_HALF_BASE},${NEEDLE_SHOULDER_Y}`, + `${CX},${NEEDLE_TIP_Y}`, + `${CX + NEEDLE_HALF_BASE},${NEEDLE_SHOULDER_Y}`, + `${CX + NEEDLE_HALF_BASE},${CY}`, +].join(" "); + +const needleVariants = cva("relative inline-block align-top", { + variants: { + size: { + sm: "w-[128px]", + md: "w-[180px]", + lg: "w-[248px]", + }, + }, + defaultVariants: { + size: "md", + }, +}); + +const pct = (value: number) => `${value.toFixed(4)}%`; + +export function ForecastNeedle({ + forecast, + baseline, + forecastLabel = "You", + baselineLabel = "Avg", + size = "md", + showAxisLabels = true, + className, + ...props +}: ForecastNeedleProps & VariantProps) { + const gradientId = React.useId(); + const [animated, setAnimated] = React.useState(false); + const [activeIndex, setActiveIndex] = React.useState(null); + + // Baseline is drawn first (behind); the user's forecast sits on top. + const needles: Needle[] = []; + if (baseline != null) { + needles.push({ + value: baseline, + label: baselineLabel, + fill: "none", + stroke: BASELINE_COLOR, + strokeWidth: BASELINE_OUTLINE_WIDTH, + }); + } + needles.push({ + value: forecast, + label: forecastLabel, + fill: FORECAST_COLOR, + stroke: OUTLINE, + strokeWidth: OUTLINE_WIDTH, + }); + + // Sweep needles in from the center (straight up) on mount. + React.useEffect(() => { + const frame = requestAnimationFrame(() => setAnimated(true)); + return () => cancelAnimationFrame(frame); + }, []); + + const ariaLabel = + "Forecast gauge: " + + needles + .map((n) => { + const value = `${Math.round(clamp01(n.value) * 100)}%`; + return n.label ? `${n.label} ${value}` : value; + }) + .join(", "); + + return ( +
+ + + + {GRADIENT_STOPS.map((stop) => ( + + ))} + + + + {/* Likelihood band: red -> white -> green ring with a flat-bottomed ink outline */} + + + {/* Needles */} + {needles.map((needle, i) => { + const rotation = animated ? valueToRotation(needle.value) : 0; + return ( + + + {/* Wider transparent hit-area for hover / tap */} + setActiveIndex(i)} + onMouseLeave={() => + setActiveIndex((cur) => (cur === i ? null : cur)) + } + onClick={() => + setActiveIndex((cur) => (cur === i ? null : i)) + } + /> + + ); + })} + + {/* Hub */} + + + + {/* Axis labels, tucked just below each lifted arc end */} + {showAxisLabels && ( + <> + + + + )} + + {/* On-hover/tap tooltip: per-needle label (if any) + percentage */} + {needles.map((needle, i) => { + if (activeIndex !== i) return null; + const tip = valueToPoint(needle.value, CX, CY, NEEDLE_R); + const left = pct((tip.x / VB_W) * 100); + const top = pct((tip.y / VB_H) * 100); + const percent = Math.round(clamp01(needle.value) * 100); + return ( + + {needle.label && ( + {needle.label} + )} + {percent}% + + ); + })} +
+ ); +} + +function AxisLabel({ x, y, text }: { x: number; y: number; text: string }) { + return ( + + {text} + + ); +} diff --git a/next.config.mjs b/next.config.mjs index 1c45e354..d11ead65 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,7 +1,18 @@ import { withSentryConfig } from "@sentry/nextjs"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +// Pin the workspace root to this project so the standalone build and Turbopack +// don't infer a parent directory (which broke `tailwindcss` resolution in dev). +const projectRoot = dirname(fileURLToPath(import.meta.url)); + /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", + outputFileTracingRoot: projectRoot, + turbopack: { + root: projectRoot, + }, images: { remotePatterns: [ {