From e3b34f7a9c6a661c2a67df82e923338c88f3bb1c Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Mon, 8 Jun 2026 17:13:27 -0500 Subject: [PATCH 01/12] Add ForecastNeedle half-wheel gauge component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cartoon-styled semicircle "needle" gauge (à la the NYT election needle) for visualizing forecast probabilities. Supports one or more independently colored needles on the same half-wheel, e.g. a user's forecast alongside a community-average baseline. - Red -> white -> green gradient arc with a dark outline and blocky needles - `color` accepts any CSS color string (Tailwind var, chart token, or hex); defaults to the theme --primary - Smooth sweep-in animation; 0% / 100% axis labels - Per-needle label + percentage surfaced in a hover/tap tooltip - Pure, exported geometry helpers with unit tests; Storybook stories Co-Authored-By: Claude Opus 4.8 (1M context) --- components/ui/forecast-needle.stories.tsx | 112 +++++++++ components/ui/forecast-needle.test.ts | 84 +++++++ components/ui/forecast-needle.tsx | 287 ++++++++++++++++++++++ 3 files changed, 483 insertions(+) create mode 100644 components/ui/forecast-needle.stories.tsx create mode 100644 components/ui/forecast-needle.test.ts create mode 100644 components/ui/forecast-needle.tsx diff --git a/components/ui/forecast-needle.stories.tsx b/components/ui/forecast-needle.stories.tsx new file mode 100644 index 0000000..27926a4 --- /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: { + size: { + control: "select", + options: ["sm", "md", "lg"], + description: "Overall width of the gauge", + }, + showAxisLabels: { + control: "boolean", + description: "Show the 0% / 50% / 100% axis labels", + }, + needles: { + control: "object", + description: + "One or more needles. Each has { value (0–1), color?, label? }.", + }, + }, + args: { + size: "md", + showAxisLabels: true, + needles: [{ value: 0.6 }], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// (a) Just a user's forecast. +export const SingleForecast: Story = { + args: { + needles: [{ value: 0.72 }], + }, +}; + +// (b) A user's forecast plus a baseline (e.g. the community average). +export const ForecastWithBaseline: Story = { + args: { + needles: [ + { value: 0.72, color: "var(--primary)", label: "You" }, + { value: 0.55, color: "var(--color-muted-foreground)", label: "Avg" }, + ], + }, +}; + +// Sanity check across the arc: low / mid / high. +export const LowMidHigh: Story = { + render: (args) => ( +
+ + + +
+ ), +}; + +// The color prop accepts any CSS color string. +export const CustomColors: Story = { + args: { + needles: [ + { value: 0.8, color: "var(--chart-1)", label: "Chart 1" }, + { value: 0.45, color: "var(--color-red-500)", label: "Red 500" }, + { value: 0.2, color: "#7c3aed", label: "Hex" }, + ], + }, +}; + +export const Sizes: Story = { + render: (args) => ( +
+ + + +
+ ), +}; + +export const NoAxisLabels: Story = { + args: { + showAxisLabels: false, + needles: [{ value: 0.68, label: "You" }], + }, +}; + +// 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 0000000..cd168ad --- /dev/null +++ b/components/ui/forecast-needle.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + clamp01, + valueToAngle, + valueToRotation, + valueToPoint, +} from "./forecast-needle"; + +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("maps 0 -> 180 (left), 0.5 -> 90 (up), 1 -> 0 (right)", () => { + expect(valueToAngle(0)).toBe(180); + expect(valueToAngle(0.5)).toBe(90); + expect(valueToAngle(1)).toBe(0); + }); + + it("is monotonically decreasing", () => { + expect(valueToAngle(0.25)).toBe(135); + expect(valueToAngle(0.75)).toBe(45); + }); + + it("clamps before mapping", () => { + expect(valueToAngle(-1)).toBe(180); + expect(valueToAngle(2)).toBe(0); + }); +}); + +describe("valueToRotation", () => { + it("maps 0 -> -90, 0.5 -> 0, 1 -> +90", () => { + expect(valueToRotation(0)).toBe(-90); + expect(valueToRotation(0.5)).toBe(0); + expect(valueToRotation(1)).toBe(90); + }); + + 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 = 100; + const cy = 95; + const r = 66; + + 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("places value 0 to the left and value 1 to the right at center height", () => { + const left = valueToPoint(0, cx, cy, r); + expect(left.x).toBeCloseTo(cx - r); + expect(left.y).toBeCloseTo(cy); + + const right = valueToPoint(1, cx, cy, r); + expect(right.x).toBeCloseTo(cx + r); + expect(right.y).toBeCloseTo(cy); + }); + + 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 0000000..00dae57 --- /dev/null +++ b/components/ui/forecast-needle.tsx @@ -0,0 +1,287 @@ +"use client"; + +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +/** + * A single needle on the half-wheel. + * - `value` is a probability in [0, 1] (clamped). + * - `color` is any CSS color string used as the needle's fill. It can be a + * Tailwind palette var (`var(--color-red-500)`), a chart token + * (`var(--chart-1)`), or a hex/rgb string. Defaults to the theme `--primary`. + * - `label` is an optional short caption shown near the needle's tip + * (e.g. "You", "Avg"). + */ +export type Needle = { + value: number; + color?: string; + label?: string; +}; + +export interface ForecastNeedleProps + extends Omit, "color"> { + /** One or more needles drawn on the same half-wheel. */ + needles: Needle[]; + size?: "sm" | "md" | "lg"; + /** Show the 0% / 50% / 100% axis labels. Defaults to true. */ + showAxisLabels?: boolean; +} + +// --------------------------------------------------------------------------- +// Geometry (pure, exported for testing) +// --------------------------------------------------------------------------- + +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: + * value 0 -> 180 (points left) + * value 0.5 -> 90 (points straight up) + * value 1 -> 0 (points right) + */ +export function valueToAngle(value: number): number { + return 180 * (1 - clamp01(value)); +} + +/** + * Clockwise SVG rotation (degrees) for a needle drawn pointing straight up at + * value 0.5: value 0 -> -90, value 0.5 -> 0, value 1 -> +90. + */ +export function valueToRotation(value: number): number { + return (clamp01(value) - 0.5) * 180; +} + +/** 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 = 200; +const VB_H = 120; +const CX = 100; +const CY = 95; +const ARC_R = 76; +const ARC_STROKE = 18; +const ARC_BORDER = 2; // dark outline thickness on each edge of the arc +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; + +// 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)" }, +]; + +const ARC_PATH = `M ${CX - ARC_R} ${CY} A ${ARC_R} ${ARC_R} 0 0 1 ${CX + ARC_R} ${CY}`; +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-[140px]", + md: "w-[200px]", + lg: "w-[280px]", + }, + }, + defaultVariants: { + size: "md", + }, +}); + +const pct = (value: number) => `${value.toFixed(4)}%`; + +export function ForecastNeedle({ + needles, + size = "md", + showAxisLabels = true, + className, + ...props +}: ForecastNeedleProps & VariantProps) { + const gradientId = React.useId(); + const [animated, setAnimated] = React.useState(false); + const [activeIndex, setActiveIndex] = React.useState(null); + + // 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 arc: dark outline behind a red -> white -> green band */} + + + + {/* Needles */} + {needles.map((needle, i) => { + const color = needle.color ?? "var(--primary)"; + 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 */} + {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} + + ); +} From ed79f936833c4abbf88eaa64c118427a808317ad Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Mon, 8 Jun 2026 17:32:13 -0500 Subject: [PATCH 02/12] Refine ForecastNeedle styling and shape - Cartoon styling: red -> white -> green gradient, blocky pencil needle and hub, dark ink outline - Sub-semicircle sweep (140deg) so the gauge is taller and less wide - Render the band as a horizontally-sliced ring (filled + single outline) so the ends are flat and parallel to the x-axis with a continuous outline - Per-needle labels moved into the hover/tap tooltip; axis labels trimmed to 0% / 100% and enlarged - Geometry parameterized via SWEEP_DEGREES; tests updated accordingly Co-Authored-By: Claude Opus 4.8 (1M context) --- components/ui/forecast-needle.test.ts | 54 +++++++++------- components/ui/forecast-needle.tsx | 92 ++++++++++++++++----------- 2 files changed, 88 insertions(+), 58 deletions(-) diff --git a/components/ui/forecast-needle.test.ts b/components/ui/forecast-needle.test.ts index cd168ad..9fb9ed1 100644 --- a/components/ui/forecast-needle.test.ts +++ b/components/ui/forecast-needle.test.ts @@ -1,11 +1,15 @@ 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); @@ -24,28 +28,28 @@ describe("clamp01", () => { }); describe("valueToAngle", () => { - it("maps 0 -> 180 (left), 0.5 -> 90 (up), 1 -> 0 (right)", () => { - expect(valueToAngle(0)).toBe(180); - expect(valueToAngle(0.5)).toBe(90); - expect(valueToAngle(1)).toBe(0); + 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", () => { - expect(valueToAngle(0.25)).toBe(135); - expect(valueToAngle(0.75)).toBe(45); + 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)).toBe(180); - expect(valueToAngle(2)).toBe(0); + expect(valueToAngle(-1)).toBeCloseTo(90 + HALF_SWEEP); + expect(valueToAngle(2)).toBeCloseTo(90 - HALF_SWEEP); }); }); describe("valueToRotation", () => { - it("maps 0 -> -90, 0.5 -> 0, 1 -> +90", () => { - expect(valueToRotation(0)).toBe(-90); - expect(valueToRotation(0.5)).toBe(0); - expect(valueToRotation(1)).toBe(90); + 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)", () => { @@ -56,9 +60,9 @@ describe("valueToRotation", () => { }); describe("valueToPoint", () => { - const cx = 100; - const cy = 95; - const r = 66; + 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); @@ -66,14 +70,20 @@ describe("valueToPoint", () => { expect(p.y).toBeCloseTo(cy - r); }); - it("places value 0 to the left and value 1 to the right at center height", () => { + it("lifts the ends symmetrically off the baseline", () => { const left = valueToPoint(0, cx, cy, r); - expect(left.x).toBeCloseTo(cx - r); - expect(left.y).toBeCloseTo(cy); - const right = valueToPoint(1, cx, cy, r); - expect(right.x).toBeCloseTo(cx + r); - expect(right.y).toBeCloseTo(cy); + + // 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", () => { diff --git a/components/ui/forecast-needle.tsx b/components/ui/forecast-needle.tsx index 00dae57..ba7e576 100644 --- a/components/ui/forecast-needle.tsx +++ b/components/ui/forecast-needle.tsx @@ -10,7 +10,7 @@ import { cn } from "@/lib/utils"; * - `color` is any CSS color string used as the needle's fill. It can be a * Tailwind palette var (`var(--color-red-500)`), a chart token * (`var(--chart-1)`), or a hex/rgb string. Defaults to the theme `--primary`. - * - `label` is an optional short caption shown near the needle's tip + * - `label` is an optional short caption shown in the hover/tap tooltip * (e.g. "You", "Avg"). */ export type Needle = { @@ -24,7 +24,7 @@ export interface ForecastNeedleProps /** One or more needles drawn on the same half-wheel. */ needles: Needle[]; size?: "sm" | "md" | "lg"; - /** Show the 0% / 50% / 100% axis labels. Defaults to true. */ + /** Show the 0% / 100% axis labels. Defaults to true. */ showAxisLabels?: boolean; } @@ -32,27 +32,35 @@ export interface ForecastNeedleProps // 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: - * value 0 -> 180 (points left) - * value 0.5 -> 90 (points straight up) - * value 1 -> 0 (points right) + * 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 180 * (1 - clamp01(value)); + 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 -> -90, value 0.5 -> 0, value 1 -> +90. + * 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) * 180; + 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. */ @@ -70,13 +78,13 @@ export function valueToPoint( // Drawing constants (in viewBox user units) // --------------------------------------------------------------------------- -const VB_W = 200; -const VB_H = 120; -const CX = 100; -const CY = 95; -const ARC_R = 76; -const ARC_STROKE = 18; -const ARC_BORDER = 2; // dark outline thickness on each edge of the arc +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 @@ -91,7 +99,26 @@ const GRADIENT_STOPS = [ { offset: "100%", color: "var(--color-green-500)" }, ]; -const ARC_PATH = `M ${CX - ARC_R} ${CY} A ${ARC_R} ${ARC_R} 0 0 1 ${CX + ARC_R} ${CY}`; +// 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. @@ -106,9 +133,9 @@ const NEEDLE_POINTS = [ const needleVariants = cva("relative inline-block align-top", { variants: { size: { - sm: "w-[140px]", - md: "w-[200px]", - lg: "w-[280px]", + sm: "w-[128px]", + md: "w-[180px]", + lg: "w-[248px]", }, }, defaultVariants: { @@ -159,9 +186,9 @@ export function ForecastNeedle({ {GRADIENT_STOPS.map((stop) => ( @@ -174,20 +201,13 @@ export function ForecastNeedle({ - {/* Likelihood arc: dark outline behind a red -> white -> green band */} + {/* Likelihood band: red -> white -> green ring with a flat-bottomed ink outline */} - {/* Needles */} @@ -243,11 +263,11 @@ export function ForecastNeedle({ /> - {/* Axis labels */} + {/* Axis labels, tucked just below each lifted arc end */} {showAxisLabels && ( <> - - + + )} From 2f0134c8c954d17c06def8321f75c9b9d185838a Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Mon, 8 Jun 2026 17:44:26 -0500 Subject: [PATCH 03/12] Simplify ForecastNeedle API to forecast + optional baseline Replace the arbitrary `needles` array with a required `forecast` and an optional `baseline`, matching the only two real use cases. Styling is now fixed by role rather than caller-controlled: - forecast: solid primary needle with an ink outline - baseline: lighter outline-only "ghost" needle in muted gray, drawn behind Tooltip labels default to "You" / "Avg" and stay overridable for non-average baselines. Stories updated to the new API. Co-Authored-By: Claude Opus 4.8 (1M context) --- components/ui/forecast-needle.stories.tsx | 84 +++++++++++------------ components/ui/forecast-needle.tsx | 74 ++++++++++++++------ 2 files changed, 94 insertions(+), 64 deletions(-) diff --git a/components/ui/forecast-needle.stories.tsx b/components/ui/forecast-needle.stories.tsx index 27926a4..9152a43 100644 --- a/components/ui/forecast-needle.stories.tsx +++ b/components/ui/forecast-needle.stories.tsx @@ -9,45 +9,52 @@ const meta = { }, 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"], - description: "Overall width of the gauge", }, showAxisLabels: { control: "boolean", - description: "Show the 0% / 50% / 100% axis labels", - }, - needles: { - control: "object", - description: - "One or more needles. Each has { value (0–1), color?, label? }.", }, }, args: { + forecast: 0.72, size: "md", showAxisLabels: true, - needles: [{ value: 0.6 }], }, } satisfies Meta; export default meta; type Story = StoryObj; -// (a) Just a user's forecast. -export const SingleForecast: Story = { +// (a) The user's forecast on its own. +export const UserForecast: Story = { args: { - needles: [{ value: 0.72 }], + forecast: 0.72, }, }; -// (b) A user's forecast plus a baseline (e.g. the community average). -export const ForecastWithBaseline: Story = { +// (b) The user's forecast plus a muted baseline (e.g. the community average). +export const WithBaseline: Story = { args: { - needles: [ - { value: 0.72, color: "var(--primary)", label: "You" }, - { value: 0.55, color: "var(--color-muted-foreground)", label: "Avg" }, - ], + forecast: 0.72, + baseline: 0.55, }, }; @@ -55,38 +62,37 @@ export const ForecastWithBaseline: Story = { export const LowMidHigh: Story = { render: (args) => (
- - - + + +
), }; -// The color prop accepts any CSS color string. -export const CustomColors: Story = { - args: { - needles: [ - { value: 0.8, color: "var(--chart-1)", label: "Chart 1" }, - { value: 0.45, color: "var(--color-red-500)", label: "Red 500" }, - { value: 0.2, color: "#7c3aed", label: "Hex" }, - ], - }, -}; - 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, - needles: [{ value: 0.68, label: "You" }], }, }; @@ -99,13 +105,7 @@ export const InContext: Story = { Will the temperature exceed 30°C tomorrow?
- +
), diff --git a/components/ui/forecast-needle.tsx b/components/ui/forecast-needle.tsx index ba7e576..8f8f41e 100644 --- a/components/ui/forecast-needle.tsx +++ b/components/ui/forecast-needle.tsx @@ -4,30 +4,33 @@ import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; -/** - * A single needle on the half-wheel. - * - `value` is a probability in [0, 1] (clamped). - * - `color` is any CSS color string used as the needle's fill. It can be a - * Tailwind palette var (`var(--color-red-500)`), a chart token - * (`var(--chart-1)`), or a hex/rgb string. Defaults to the theme `--primary`. - * - `label` is an optional short caption shown in the hover/tap tooltip - * (e.g. "You", "Avg"). - */ -export type Needle = { - value: number; - color?: string; - label?: string; -}; - export interface ForecastNeedleProps extends Omit, "color"> { - /** One or more needles drawn on the same half-wheel. */ - needles: Needle[]; + /** 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) // --------------------------------------------------------------------------- @@ -92,6 +95,12 @@ 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)" }, @@ -146,7 +155,10 @@ const needleVariants = cva("relative inline-block align-top", { const pct = (value: number) => `${value.toFixed(4)}%`; export function ForecastNeedle({ - needles, + forecast, + baseline, + forecastLabel = "You", + baselineLabel = "Avg", size = "md", showAxisLabels = true, className, @@ -156,6 +168,25 @@ export function ForecastNeedle({ 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)); @@ -212,7 +243,6 @@ export function ForecastNeedle({ {/* Needles */} {needles.map((needle, i) => { - const color = needle.color ?? "var(--primary)"; const rotation = animated ? valueToRotation(needle.value) : 0; return ( {/* Wider transparent hit-area for hover / tap */} From 265681815dd8d4178a9db5b7e12b294359ab54e3 Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Mon, 8 Jun 2026 18:06:06 -0500 Subject: [PATCH 04/12] Add Storybook stories for EditableForecastCard Stories cover the default (has-forecast), empty, low/high probability, and long-text states. The slider and percent input are interactive. To render a component that module-imports server actions, alias @/lib/db_actions and @/lib/db_actions/props to success-returning stubs in Storybook only, so the Postgres client and next/cache stay out of the bundle. Shared mock data lives in forecast-card.fixtures.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .storybook/main.ts | 9 +++ .storybook/mocks/db_actions-props.ts | 4 + .storybook/mocks/db_actions.ts | 6 ++ .../editable-forecast-card.stories.tsx | 79 +++++++++++++++++++ .../forecast-card/forecast-card.fixtures.ts | 35 ++++++++ 5 files changed, 133 insertions(+) create mode 100644 .storybook/mocks/db_actions-props.ts create mode 100644 .storybook/mocks/db_actions.ts create mode 100644 components/forecast-card/editable-forecast-card.stories.tsx create mode 100644 components/forecast-card/forecast-card.fixtures.ts diff --git a/.storybook/main.ts b/.storybook/main.ts index 391e0f6..643ae5e 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -22,6 +22,15 @@ 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, ...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 0000000..ab8df18 --- /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 0000000..48585c2 --- /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/components/forecast-card/editable-forecast-card.stories.tsx b/components/forecast-card/editable-forecast-card.stories.tsx new file mode 100644 index 0000000..60c073b --- /dev/null +++ b/components/forecast-card/editable-forecast-card.stories.tsx @@ -0,0 +1,79 @@ +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 after you drag the slider or type a new value (i.e. once the +// local forecast differs from the saved one). 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; + +// The user already has a forecast: probability box, filled slider, and handle. +export const Default: Story = { + args: { + prop: makeProp({ user_forecast: 0.6, user_forecast_id: 10 }), + }, +}; + +// No forecast yet: "—" box and a "Click to set forecast" slider hint. +export const NoForecast: Story = { + args: { + prop: makeProp({ user_forecast: null, user_forecast_id: null }), + }, +}; + +// Low probability -> red color scale on the box, bar, and handle. +export const LowProbability: Story = { + args: { + prop: makeProp({ user_forecast: 0.12, user_forecast_id: 10 }), + }, +}; + +// High probability -> green color scale. +export const HighProbability: Story = { + args: { + prop: makeProp({ user_forecast: 0.91, user_forecast_id: 10 }), + }, +}; + +// Long prop text wraps; markdown is rendered. +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.", + }), + }, +}; diff --git a/components/forecast-card/forecast-card.fixtures.ts b/components/forecast-card/forecast-card.fixtures.ts new file mode 100644 index 0000000..f393505 --- /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, + }; +} From f449184de882dc8b0041e8efc60f88f13e704a56 Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Mon, 8 Jun 2026 18:12:45 -0500 Subject: [PATCH 05/12] Add Storybook stories for ForecastCard Stories cover the color scale, the you-vs-avg comparison bar (including the identical and label-collision cases), the no-forecast placeholder, status badges, and markdown/long-text rendering. ForecastCard imports next/link, whose app-router internals trip a circular-import TDZ under Vite's dev transform. Mock next/link to a plain anchor in Storybook (as @storybook/nextjs does) to render it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .storybook/main.ts | 3 + .storybook/mocks/next-link.tsx | 28 ++++ .../forecast-card/forecast-card.stories.tsx | 134 ++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 .storybook/mocks/next-link.tsx create mode 100644 components/forecast-card/forecast-card.stories.tsx diff --git a/.storybook/main.ts b/.storybook/main.ts index 643ae5e..4dece48 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -31,6 +31,9 @@ const config: StorybookConfig = { ).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/next-link.tsx b/.storybook/mocks/next-link.tsx new file mode 100644 index 0000000..161d4c4 --- /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/forecast-card.stories.tsx b/components/forecast-card/forecast-card.stories.tsx new file mode 100644 index 0000000..9f47a9d --- /dev/null +++ b/components/forecast-card/forecast-card.stories.tsx @@ -0,0 +1,134 @@ +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 comparison bar", + }, + 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 -> placeholder box, no comparison bar. +export const NoForecast: Story = { + args: { + prop: makeProp({ user_forecast: null, user_forecast_id: null }), + showCommunityAvg: true, + }, +}; + +// Low probability -> red color scale. +export const LowProbability: Story = { + args: { + prop: makeProp({ user_forecast: 0.12, community_average: 0.25 }), + showCommunityAvg: true, + }, +}; + +// High probability -> green color scale. +export const HighProbability: Story = { + args: { + prop: makeProp({ user_forecast: 0.91, community_average: 0.8 }), + showCommunityAvg: true, + }, +}; + +// "you" and "avg" land on the same value -> combined "you / avg" label. +export const YouAndAvgIdentical: Story = { + args: { + prop: makeProp({ user_forecast: 0.6, community_average: 0.6 }), + showCommunityAvg: true, + }, +}; + +// "you" and "avg" within ~12% -> collision-avoidance label offsetting. +export const YouAndAvgClose: Story = { + args: { + prop: makeProp({ user_forecast: 0.55, community_average: 0.5 }), + 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, + }, +}; From 368657158408dafe096ee2fda9ecbb7aa5348039 Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Mon, 8 Jun 2026 18:34:30 -0500 Subject: [PATCH 06/12] Add needle-in-card layout experiments Throwaway mockups exploring how to fold the ForecastNeedle into the forecast card: NeedleRight (compact needle as a right-hand summary), NeedleHero (needle as the left hero panel), and NeedleTile (centered redesign). Shown side by side in a Storybook gallery for picking a direction. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../needle-experiments.stories.tsx | 95 ++++++++++ .../forecast-card/needle-experiments.tsx | 174 ++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 components/forecast-card/needle-experiments.stories.tsx create mode 100644 components/forecast-card/needle-experiments.tsx diff --git a/components/forecast-card/needle-experiments.stories.tsx b/components/forecast-card/needle-experiments.stories.tsx new file mode 100644 index 0000000..8204bc6 --- /dev/null +++ b/components/forecast-card/needle-experiments.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ComponentType, ReactNode } from "react"; +import type { PropWithUserForecast } from "@/types/db_types"; +import { + ForecastCardNeedleRight, + ForecastCardNeedleHero, + ForecastCardNeedleTile, +} from "./needle-experiments"; +import { makeProp } from "./forecast-card.fixtures"; + +// Throwaway gallery for picking a needle-in-card direction. +const meta = { + title: "Forecast/Needle Experiments", + parameters: { + layout: "padded", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +type VariantComponent = ComponentType<{ + prop: PropWithUserForecast; + showCommunityAvg: boolean; +}>; + +const VARIANTS: { name: string; Component: VariantComponent }[] = [ + { name: "1 · NeedleRight", Component: ForecastCardNeedleRight }, + { name: "2 · NeedleHero", Component: ForecastCardNeedleHero }, + { name: "3 · NeedleTile", Component: ForecastCardNeedleTile }, +]; + +const withAvg = makeProp({ user_forecast: 0.72, community_average: 0.58 }); +const noAvg = makeProp({ user_forecast: 0.64, community_average: null }); +const low = makeProp({ user_forecast: 0.14, community_average: 0.3 }); +const high = makeProp({ user_forecast: 0.9, community_average: 0.78 }); +const noForecast = makeProp({ user_forecast: null, user_forecast_id: null }); + +function Labeled({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
+ {label} +
+ {children} +
+ ); +} + +function StatesStack({ Component }: { Component: VariantComponent }) { + return ( +
+ + + + + + + + + + + + + + + +
+ ); +} + +// All three directions for the same prop, stacked for a direct comparison. +export const Compare: Story = { + render: () => ( +
+ {VARIANTS.map(({ name, Component }) => ( + + + + ))} +
+ ), +}; + +export const NeedleRight: Story = { + render: () => , +}; + +export const NeedleHero: Story = { + render: () => , +}; + +export const NeedleTile: Story = { + render: () => , +}; diff --git a/components/forecast-card/needle-experiments.tsx b/components/forecast-card/needle-experiments.tsx new file mode 100644 index 0000000..07b15b9 --- /dev/null +++ b/components/forecast-card/needle-experiments.tsx @@ -0,0 +1,174 @@ +"use client"; + +// EXPERIMENTAL — throwaway mockups exploring how to fold the ForecastNeedle +// into the forecast card. Once a direction is chosen, the winner gets merged +// into ForecastCard and this file is deleted. + +import { PropWithUserForecast } from "@/types/db_types"; +import { Badge } from "@/components/ui/badge"; +import { PropStatusBadge } from "@/components/ui/prop-status-badge"; +import { getPropStatusFromProp } from "@/lib/prop-status"; +import { MarkdownRenderer } from "@/components/markdown"; +import { ForecastNeedle } from "@/components/ui/forecast-needle"; +import { cn } from "@/lib/utils"; +import Link from "next/link"; + +interface VariantProps { + prop: PropWithUserForecast; + showCommunityAvg: boolean; +} + +const cardClass = + "bg-card rounded-lg border border-border p-5 hover:shadow-md transition-shadow"; + +function baselineOf(prop: PropWithUserForecast, showCommunityAvg: boolean) { + return showCommunityAvg && prop.community_average != null + ? prop.community_average + : undefined; +} + +function MetaRow({ prop }: { prop: PropWithUserForecast }) { + return ( +
+ + {prop.category_name} + + +
+ ); +} + +function PropText({ + prop, + centered = false, +}: { + prop: PropWithUserForecast; + centered?: boolean; +}) { + return ( +
+

+ {prop.prop_text} +

+

+ {prop.prop_notes || " "} +

+
+ ); +} + +function PercentReadout({ + value, + className, +}: { + value: number | null; + className?: string; +}) { + return ( +
+ {value != null ? `${Math.round(value * 100)}%` : "—"} +
+ ); +} + +function EmptyState({ className }: { className?: string }) { + return ( +
+ No forecast yet +
+ ); +} + +// 1. Needle on the right as a compact summary panel (text on the left). +export function ForecastCardNeedleRight({ prop, showCommunityAvg }: VariantProps) { + const forecast = prop.user_forecast; + const baseline = baselineOf(prop, showCommunityAvg); + return ( + +
+
+ + +
+
+ {forecast != null ? ( + <> + + + {baseline != null && ( +
+ Average: {Math.round(baseline * 100)}% +
+ )} + + ) : ( + + )} +
+
+ + ); +} + +// 2. Needle as the prominent left "hero" panel, replacing the % box. +export function ForecastCardNeedleHero({ prop, showCommunityAvg }: VariantProps) { + const forecast = prop.user_forecast; + const baseline = baselineOf(prop, showCommunityAvg); + return ( +
+
+
+ {forecast != null ? ( + <> + + + + ) : ( + + )} +
+
+ + +
+
+
+ ); +} + +// 3. Centered tile: badges + prop text on top, needle below. +export function ForecastCardNeedleTile({ prop, showCommunityAvg }: VariantProps) { + const forecast = prop.user_forecast; + const baseline = baselineOf(prop, showCommunityAvg); + return ( +
+ +
+ +
+
+ {forecast != null ? ( + <> + + + + ) : ( + + )} +
+
+ ); +} From 0d939a34850865686c4789080ac18cbf6c44b411 Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Mon, 8 Jun 2026 18:36:29 -0500 Subject: [PATCH 07/12] Remove unchosen needle-card variants Drop the NeedleHero and NeedleTile mockups; keep only NeedleRight, the chosen direction. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../needle-experiments.stories.tsx | 81 +++---------------- .../forecast-card/needle-experiments.tsx | 68 +--------------- 2 files changed, 11 insertions(+), 138 deletions(-) diff --git a/components/forecast-card/needle-experiments.stories.tsx b/components/forecast-card/needle-experiments.stories.tsx index 8204bc6..1404419 100644 --- a/components/forecast-card/needle-experiments.stories.tsx +++ b/components/forecast-card/needle-experiments.stories.tsx @@ -1,14 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { ComponentType, ReactNode } from "react"; -import type { PropWithUserForecast } from "@/types/db_types"; -import { - ForecastCardNeedleRight, - ForecastCardNeedleHero, - ForecastCardNeedleTile, -} from "./needle-experiments"; +import { ForecastCardNeedleRight } from "./needle-experiments"; import { makeProp } from "./forecast-card.fixtures"; -// Throwaway gallery for picking a needle-in-card direction. +// Throwaway gallery for the chosen needle-in-card direction (NeedleRight). const meta = { title: "Forecast/Needle Experiments", parameters: { @@ -19,77 +13,20 @@ const meta = { export default meta; type Story = StoryObj; -type VariantComponent = ComponentType<{ - prop: PropWithUserForecast; - showCommunityAvg: boolean; -}>; - -const VARIANTS: { name: string; Component: VariantComponent }[] = [ - { name: "1 · NeedleRight", Component: ForecastCardNeedleRight }, - { name: "2 · NeedleHero", Component: ForecastCardNeedleHero }, - { name: "3 · NeedleTile", Component: ForecastCardNeedleTile }, -]; - const withAvg = makeProp({ user_forecast: 0.72, community_average: 0.58 }); const noAvg = makeProp({ user_forecast: 0.64, community_average: null }); const low = makeProp({ user_forecast: 0.14, community_average: 0.3 }); const high = makeProp({ user_forecast: 0.9, community_average: 0.78 }); const noForecast = makeProp({ user_forecast: null, user_forecast_id: null }); -function Labeled({ label, children }: { label: string; children: ReactNode }) { - return ( -
-
- {label} -
- {children} -
- ); -} - -function StatesStack({ Component }: { Component: VariantComponent }) { - return ( -
- - - - - - - - - - - - - - - -
- ); -} - -// All three directions for the same prop, stacked for a direct comparison. -export const Compare: Story = { +export const NeedleRight: Story = { render: () => ( -
- {VARIANTS.map(({ name, Component }) => ( - - - - ))} +
+ + + + +
), }; - -export const NeedleRight: Story = { - render: () => , -}; - -export const NeedleHero: Story = { - render: () => , -}; - -export const NeedleTile: Story = { - render: () => , -}; diff --git a/components/forecast-card/needle-experiments.tsx b/components/forecast-card/needle-experiments.tsx index 07b15b9..fb166b3 100644 --- a/components/forecast-card/needle-experiments.tsx +++ b/components/forecast-card/needle-experiments.tsx @@ -38,15 +38,9 @@ function MetaRow({ prop }: { prop: PropWithUserForecast }) { ); } -function PropText({ - prop, - centered = false, -}: { - prop: PropWithUserForecast; - centered?: boolean; -}) { +function PropText({ prop }: { prop: PropWithUserForecast }) { return ( -
+

{prop.prop_text}

@@ -114,61 +108,3 @@ export function ForecastCardNeedleRight({ prop, showCommunityAvg }: VariantProps ); } - -// 2. Needle as the prominent left "hero" panel, replacing the % box. -export function ForecastCardNeedleHero({ prop, showCommunityAvg }: VariantProps) { - const forecast = prop.user_forecast; - const baseline = baselineOf(prop, showCommunityAvg); - return ( -
-
-
- {forecast != null ? ( - <> - - - - ) : ( - - )} -
-
- - -
-
-
- ); -} - -// 3. Centered tile: badges + prop text on top, needle below. -export function ForecastCardNeedleTile({ prop, showCommunityAvg }: VariantProps) { - const forecast = prop.user_forecast; - const baseline = baselineOf(prop, showCommunityAvg); - return ( -
- -
- -
-
- {forecast != null ? ( - <> - - - - ) : ( - - )} -
-
- ); -} From 5c1a7df8fdfff3e968ddfe0497d66391c7131326 Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Mon, 8 Jun 2026 18:39:08 -0500 Subject: [PATCH 08/12] Use the ForecastNeedle in ForecastCard Replace the colored % box and the you/avg comparison bar with the needle layout chosen from the experiments: the user's forecast as the primary needle with the community average as a muted ghost needle, a bold % readout, and an "Average: NN%" line. The whole card is now a link to the prop details (the external-link button is gone), and the badges sit top-left. Same { prop, showCommunityAvg } API, so call sites are unchanged. Delete the throwaway needle-experiments files; drop the comparison-bar collision stories. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../forecast-card/forecast-card.stories.tsx | 21 +- components/forecast-card/forecast-card.tsx | 256 +++--------------- .../needle-experiments.stories.tsx | 32 --- .../forecast-card/needle-experiments.tsx | 110 -------- 4 files changed, 52 insertions(+), 367 deletions(-) delete mode 100644 components/forecast-card/needle-experiments.stories.tsx delete mode 100644 components/forecast-card/needle-experiments.tsx diff --git a/components/forecast-card/forecast-card.stories.tsx b/components/forecast-card/forecast-card.stories.tsx index 9f47a9d..efb8662 100644 --- a/components/forecast-card/forecast-card.stories.tsx +++ b/components/forecast-card/forecast-card.stories.tsx @@ -19,7 +19,8 @@ const meta = { argTypes: { showCommunityAvg: { control: "boolean", - description: "Whether to show the community-average comparison bar", + description: + "Whether to show the community average (baseline ghost needle + readout)", }, prop: { control: false, @@ -50,7 +51,7 @@ export const WithoutCommunityAverage: Story = { }, }; -// The user hasn't forecasted yet -> placeholder box, no comparison bar. +// The user hasn't forecasted yet -> "No forecast yet" placeholder. export const NoForecast: Story = { args: { prop: makeProp({ user_forecast: null, user_forecast_id: null }), @@ -58,7 +59,7 @@ export const NoForecast: Story = { }, }; -// Low probability -> red color scale. +// Low probability -> needle points left, into the red. export const LowProbability: Story = { args: { prop: makeProp({ user_forecast: 0.12, community_average: 0.25 }), @@ -66,7 +67,7 @@ export const LowProbability: Story = { }, }; -// High probability -> green color scale. +// High probability -> needle points right, into the green. export const HighProbability: Story = { args: { prop: makeProp({ user_forecast: 0.91, community_average: 0.8 }), @@ -74,22 +75,14 @@ export const HighProbability: Story = { }, }; -// "you" and "avg" land on the same value -> combined "you / avg" label. -export const YouAndAvgIdentical: Story = { +// 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, }, }; -// "you" and "avg" within ~12% -> collision-avoidance label offsetting. -export const YouAndAvgClose: Story = { - args: { - prop: makeProp({ user_forecast: 0.55, community_average: 0.5 }), - showCommunityAvg: true, - }, -}; - // Past the forecast deadline -> "Closed" status badge. export const Closed: Story = { args: { diff --git a/components/forecast-card/forecast-card.tsx b/components/forecast-card/forecast-card.tsx index 479f3a1..2e6420d 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/forecast-card/needle-experiments.stories.tsx b/components/forecast-card/needle-experiments.stories.tsx deleted file mode 100644 index 1404419..0000000 --- a/components/forecast-card/needle-experiments.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ForecastCardNeedleRight } from "./needle-experiments"; -import { makeProp } from "./forecast-card.fixtures"; - -// Throwaway gallery for the chosen needle-in-card direction (NeedleRight). -const meta = { - title: "Forecast/Needle Experiments", - parameters: { - layout: "padded", - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -const withAvg = makeProp({ user_forecast: 0.72, community_average: 0.58 }); -const noAvg = makeProp({ user_forecast: 0.64, community_average: null }); -const low = makeProp({ user_forecast: 0.14, community_average: 0.3 }); -const high = makeProp({ user_forecast: 0.9, community_average: 0.78 }); -const noForecast = makeProp({ user_forecast: null, user_forecast_id: null }); - -export const NeedleRight: Story = { - render: () => ( -
- - - - - -
- ), -}; diff --git a/components/forecast-card/needle-experiments.tsx b/components/forecast-card/needle-experiments.tsx deleted file mode 100644 index fb166b3..0000000 --- a/components/forecast-card/needle-experiments.tsx +++ /dev/null @@ -1,110 +0,0 @@ -"use client"; - -// EXPERIMENTAL — throwaway mockups exploring how to fold the ForecastNeedle -// into the forecast card. Once a direction is chosen, the winner gets merged -// into ForecastCard and this file is deleted. - -import { PropWithUserForecast } from "@/types/db_types"; -import { Badge } from "@/components/ui/badge"; -import { PropStatusBadge } from "@/components/ui/prop-status-badge"; -import { getPropStatusFromProp } from "@/lib/prop-status"; -import { MarkdownRenderer } from "@/components/markdown"; -import { ForecastNeedle } from "@/components/ui/forecast-needle"; -import { cn } from "@/lib/utils"; -import Link from "next/link"; - -interface VariantProps { - prop: PropWithUserForecast; - showCommunityAvg: boolean; -} - -const cardClass = - "bg-card rounded-lg border border-border p-5 hover:shadow-md transition-shadow"; - -function baselineOf(prop: PropWithUserForecast, showCommunityAvg: boolean) { - return showCommunityAvg && prop.community_average != null - ? prop.community_average - : undefined; -} - -function MetaRow({ prop }: { prop: PropWithUserForecast }) { - return ( -
- - {prop.category_name} - - -
- ); -} - -function PropText({ prop }: { prop: PropWithUserForecast }) { - return ( -
-

- {prop.prop_text} -

-

- {prop.prop_notes || " "} -

-
- ); -} - -function PercentReadout({ - value, - className, -}: { - value: number | null; - className?: string; -}) { - return ( -
- {value != null ? `${Math.round(value * 100)}%` : "—"} -
- ); -} - -function EmptyState({ className }: { className?: string }) { - return ( -
- No forecast yet -
- ); -} - -// 1. Needle on the right as a compact summary panel (text on the left). -export function ForecastCardNeedleRight({ prop, showCommunityAvg }: VariantProps) { - const forecast = prop.user_forecast; - const baseline = baselineOf(prop, showCommunityAvg); - return ( - -
-
- - -
-
- {forecast != null ? ( - <> - - - {baseline != null && ( -
- Average: {Math.round(baseline * 100)}% -
- )} - - ) : ( - - )} -
-
- - ); -} From 00f50a1b12ddedb4e29249337f9f82f11b21dcaf Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Mon, 8 Jun 2026 20:53:26 -0500 Subject: [PATCH 09/12] Add editable-card needle mockup (NeedleRight) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Throwaway mock for folding the ForecastNeedle into the editable forecast card: the needle (live preview + community-average ghost) on the right with a number box as the input, an inline edit pencil beside the prop text, and the unsaved-changes ring + Save/Cancel. Local state only — saving is faked. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editable-needle-experiments.stories.tsx | 27 +++ .../editable-needle-experiments.tsx | 207 ++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 components/forecast-card/editable-needle-experiments.stories.tsx create mode 100644 components/forecast-card/editable-needle-experiments.tsx diff --git a/components/forecast-card/editable-needle-experiments.stories.tsx b/components/forecast-card/editable-needle-experiments.stories.tsx new file mode 100644 index 0000000..256f67d --- /dev/null +++ b/components/forecast-card/editable-needle-experiments.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { EditableNeedleRight } from "./editable-needle-experiments"; +import { makeProp } from "./forecast-card.fixtures"; + +// Throwaway gallery for the chosen editable needle direction (NeedleRight). +// Live (local state only) — type in the % box to see the needle update. +const meta = { + title: "Forecast/Editable Needle Experiments", + parameters: { + layout: "padded", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const withAvg = makeProp({ user_forecast: 0.6, community_average: 0.48 }); +const noForecast = makeProp({ user_forecast: null, user_forecast_id: null }); + +export const NeedleRight: Story = { + render: () => ( +
+ + +
+ ), +}; diff --git a/components/forecast-card/editable-needle-experiments.tsx b/components/forecast-card/editable-needle-experiments.tsx new file mode 100644 index 0000000..9443071 --- /dev/null +++ b/components/forecast-card/editable-needle-experiments.tsx @@ -0,0 +1,207 @@ +"use client"; + +// EXPERIMENTAL — throwaway mockups for folding the ForecastNeedle into the +// *editable* forecast card. The slider stays the editor; these explore where +// the needle (live preview + community-average ghost) sits relative to it. +// Local state only — saving is faked. Deleted once a direction is chosen. + +import { useState } from "react"; +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 { MarkdownRenderer } from "@/components/markdown"; +import { ForecastNeedle } from "@/components/ui/forecast-needle"; +import { cn } from "@/lib/utils"; +import { Pencil } from "lucide-react"; + +interface VariantProps { + prop: PropWithUserForecast; + showCommunityAvg: boolean; +} + +const cardBase = "rounded-lg border bg-card p-5 transition-all"; + +function baselineOf(prop: PropWithUserForecast, showCommunityAvg: boolean) { + return showCommunityAvg && prop.community_average != null + ? prop.community_average + : undefined; +} + +function MetaRow({ prop }: { prop: PropWithUserForecast }) { + return ( +
+ + {prop.category_name} + + +
+ ); +} + +function PropText({ prop }: { prop: PropWithUserForecast }) { + return ( +
+
+

+ {prop.prop_text} +

+

+ {prop.prop_notes || " "} +

+
+ +
+ ); +} + +function SaveCancel({ show, onCancel }: { show: boolean; onCancel: () => void }) { + if (!show) return null; + return ( +
+ + +
+ ); +} + +// Raw number entry: type a percentage (0–100). Commits on Enter/blur, Escape +// reverts. While unfocused it mirrors the slider; while focused it holds a draft. +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 ( + + ); +} + +function NeedleColumn({ + value, + baseline, + onChange, + className, +}: { + value: number | null; + baseline: number | undefined; + onChange: (v: number) => void; + className?: string; +}) { + return ( +
+ {value != null ? ( + <> + + + {baseline != null && ( +
+ Average: {Math.round(baseline * 100)}% +
+ )} + + ) : ( + <> +
No forecast yet
+ + + )} +
+ ); +} + +function useLocalForecast(prop: PropWithUserForecast) { + const [value, setValue] = useState(prop.user_forecast); + const hasChanges = value !== prop.user_forecast; + const reset = () => setValue(prop.user_forecast); + return { value, setValue, hasChanges, reset }; +} + +function changeRing(hasChanges: boolean) { + return hasChanges + ? "border-blue-300 ring-2 ring-blue-100" + : "border-border hover:border-muted-foreground/30"; +} + +// Needle on the right (matching the display ForecastCard); the number box under +// it is the input. Viewing and editing keep the needle in the same place. +export function EditableNeedleRight({ prop, showCommunityAvg }: VariantProps) { + const { value, setValue, hasChanges, reset } = useLocalForecast(prop); + const baseline = baselineOf(prop, showCommunityAvg); + return ( +
+
+
+ + + +
+ +
+
+ ); +} From f1727f35c2d36ff2eb1a6ddb0611f82524c6ce9f Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Mon, 8 Jun 2026 21:34:32 -0500 Subject: [PATCH 10/12] Wrap long notes in editable needle mock; add long-text stories Notes used `truncate` (nowrap) and spilled off the side; switch to wrapping. Add LongPropText and LongNotes stories to exercise overflow. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editable-needle-experiments.stories.tsx | 32 +++++++++++++++++++ .../editable-needle-experiments.tsx | 4 +-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/components/forecast-card/editable-needle-experiments.stories.tsx b/components/forecast-card/editable-needle-experiments.stories.tsx index 256f67d..3a8cb2d 100644 --- a/components/forecast-card/editable-needle-experiments.stories.tsx +++ b/components/forecast-card/editable-needle-experiments.stories.tsx @@ -17,6 +17,20 @@ type Story = StoryObj; const withAvg = makeProp({ user_forecast: 0.6, community_average: 0.48 }); const noForecast = makeProp({ user_forecast: null, user_forecast_id: null }); +const longPropText = makeProp({ + user_forecast: 0.6, + community_average: 0.48, + prop_text: + "Will at least three of the five largest economies by nominal GDP simultaneously report two consecutive quarters of negative real GDP growth at any point before the end of the calendar year, as measured by each country's official national statistics agency?", +}); + +const longNotes = makeProp({ + user_forecast: 0.6, + community_average: 0.48, + 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.", +}); + export const NeedleRight: Story = { render: () => (
@@ -25,3 +39,21 @@ export const NeedleRight: Story = {
), }; + +// Much longer prop text -> the title wraps; check the pencil + needle alignment. +export const LongPropText: Story = { + render: () => ( +
+ +
+ ), +}; + +// Much longer notes text -> the description line should wrap, not spill. +export const LongNotes: Story = { + render: () => ( +
+ +
+ ), +}; diff --git a/components/forecast-card/editable-needle-experiments.tsx b/components/forecast-card/editable-needle-experiments.tsx index 9443071..b50d702 100644 --- a/components/forecast-card/editable-needle-experiments.tsx +++ b/components/forecast-card/editable-needle-experiments.tsx @@ -47,8 +47,8 @@ function PropText({ prop }: { prop: PropWithUserForecast }) {

{prop.prop_text}

-

- {prop.prop_notes || " "} +

+ {prop.prop_notes || " "}

+ + )}
- - - -
-

- {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/editable-needle-experiments.stories.tsx b/components/forecast-card/editable-needle-experiments.stories.tsx deleted file mode 100644 index 3a8cb2d..0000000 --- a/components/forecast-card/editable-needle-experiments.stories.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { EditableNeedleRight } from "./editable-needle-experiments"; -import { makeProp } from "./forecast-card.fixtures"; - -// Throwaway gallery for the chosen editable needle direction (NeedleRight). -// Live (local state only) — type in the % box to see the needle update. -const meta = { - title: "Forecast/Editable Needle Experiments", - parameters: { - layout: "padded", - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -const withAvg = makeProp({ user_forecast: 0.6, community_average: 0.48 }); -const noForecast = makeProp({ user_forecast: null, user_forecast_id: null }); - -const longPropText = makeProp({ - user_forecast: 0.6, - community_average: 0.48, - prop_text: - "Will at least three of the five largest economies by nominal GDP simultaneously report two consecutive quarters of negative real GDP growth at any point before the end of the calendar year, as measured by each country's official national statistics agency?", -}); - -const longNotes = makeProp({ - user_forecast: 0.6, - community_average: 0.48, - 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.", -}); - -export const NeedleRight: Story = { - render: () => ( -
- - -
- ), -}; - -// Much longer prop text -> the title wraps; check the pencil + needle alignment. -export const LongPropText: Story = { - render: () => ( -
- -
- ), -}; - -// Much longer notes text -> the description line should wrap, not spill. -export const LongNotes: Story = { - render: () => ( -
- -
- ), -}; diff --git a/components/forecast-card/editable-needle-experiments.tsx b/components/forecast-card/editable-needle-experiments.tsx deleted file mode 100644 index b50d702..0000000 --- a/components/forecast-card/editable-needle-experiments.tsx +++ /dev/null @@ -1,207 +0,0 @@ -"use client"; - -// EXPERIMENTAL — throwaway mockups for folding the ForecastNeedle into the -// *editable* forecast card. The slider stays the editor; these explore where -// the needle (live preview + community-average ghost) sits relative to it. -// Local state only — saving is faked. Deleted once a direction is chosen. - -import { useState } from "react"; -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 { MarkdownRenderer } from "@/components/markdown"; -import { ForecastNeedle } from "@/components/ui/forecast-needle"; -import { cn } from "@/lib/utils"; -import { Pencil } from "lucide-react"; - -interface VariantProps { - prop: PropWithUserForecast; - showCommunityAvg: boolean; -} - -const cardBase = "rounded-lg border bg-card p-5 transition-all"; - -function baselineOf(prop: PropWithUserForecast, showCommunityAvg: boolean) { - return showCommunityAvg && prop.community_average != null - ? prop.community_average - : undefined; -} - -function MetaRow({ prop }: { prop: PropWithUserForecast }) { - return ( -
- - {prop.category_name} - - -
- ); -} - -function PropText({ prop }: { prop: PropWithUserForecast }) { - return ( -
-
-

- {prop.prop_text} -

-

- {prop.prop_notes || " "} -

-
- -
- ); -} - -function SaveCancel({ show, onCancel }: { show: boolean; onCancel: () => void }) { - if (!show) return null; - return ( -
- - -
- ); -} - -// Raw number entry: type a percentage (0–100). Commits on Enter/blur, Escape -// reverts. While unfocused it mirrors the slider; while focused it holds a draft. -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 ( - - ); -} - -function NeedleColumn({ - value, - baseline, - onChange, - className, -}: { - value: number | null; - baseline: number | undefined; - onChange: (v: number) => void; - className?: string; -}) { - return ( -
- {value != null ? ( - <> - - - {baseline != null && ( -
- Average: {Math.round(baseline * 100)}% -
- )} - - ) : ( - <> -
No forecast yet
- - - )} -
- ); -} - -function useLocalForecast(prop: PropWithUserForecast) { - const [value, setValue] = useState(prop.user_forecast); - const hasChanges = value !== prop.user_forecast; - const reset = () => setValue(prop.user_forecast); - return { value, setValue, hasChanges, reset }; -} - -function changeRing(hasChanges: boolean) { - return hasChanges - ? "border-blue-300 ring-2 ring-blue-100" - : "border-border hover:border-muted-foreground/30"; -} - -// Needle on the right (matching the display ForecastCard); the number box under -// it is the input. Viewing and editing keep the needle in the same place. -export function EditableNeedleRight({ prop, showCommunityAvg }: VariantProps) { - const { value, setValue, hasChanges, reset } = useLocalForecast(prop); - const baseline = baselineOf(prop, showCommunityAvg); - return ( -
-
-
- - - -
- -
-
- ); -} From 639f549c68720bd4bf21ba30e18f661e825d507c Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Tue, 9 Jun 2026 08:18:04 -0500 Subject: [PATCH 12/12] Pin Next workspace root to the project directory Set outputFileTracingRoot and turbopack.root to this project so Next/Turbopack don't infer a parent directory as the workspace root. On a nested checkout that mis-rooting poisoned the build/cache and made webpack (via @sentry/nextjs) try to resolve `tailwindcss` from the parent folder, breaking `next dev`. Co-Authored-By: Claude Opus 4.8 (1M context) --- next.config.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/next.config.mjs b/next.config.mjs index 1c45e35..d11ead6 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: [ {