Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/screenshots/impact-signals-grid.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/impact-signals-livebar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/impact-signals-modal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/impact-signals-platforms.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 6 additions & 2 deletions src/app/impact/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import MeasuringQuestions from '@/components/MeasuringQuestions'
import { fetchSimocracyStats } from '@/lib/simocracy'
import { fetchGainforestStats } from '@/lib/gainforest'
import { fetchGlowStats } from '@/lib/glow'
import { resolveAllSignals } from '@/lib/market-signals'
import { FIELD_COLOR, HAND_COLOR } from '@/lib/inflection-points'

// Pull live output metrics for the Economies & Governance inflection points from
Expand Down Expand Up @@ -60,7 +61,10 @@ export const metadata: Metadata = {
}

export default async function ImpactPage() {
const liveOutputs = await fetchLiveOutputs()
const [liveOutputs, marketSignals] = await Promise.all([
fetchLiveOutputs(),
resolveAllSignals(),
])
return (
<div>
{/* Hero */}
Expand Down Expand Up @@ -98,7 +102,7 @@ export default async function ImpactPage() {
Select a focus area. Each card shows the defined threshold (did it happen), the cascade we
expect if it matters, and the PL contribution we would trace.
</p>
<ImpactDashboard liveOutputs={liveOutputs} />
<ImpactDashboard liveOutputs={liveOutputs} marketSignals={marketSignals} />
</div>
</section>

Expand Down
169 changes: 140 additions & 29 deletions src/components/ImpactDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,20 @@ import {
type PLRole,
} from '@/lib/inflection-points'
import { AreaIcon, type AreaIconType } from '@/components/AreaIcons'
import type { MarketSignal } from '@/lib/market-signals'

type Filter = FocusAreaKey

/** Live output metrics for a point, keyed by the point's title. Fetched server-side. */
export type LiveMetric = { value: string; label: string }
export type LiveOutputs = Record<string, LiveMetric[]>
/** Prediction-market crowd signal per point, keyed by title. Fetched server-side. */
export type MarketSignals = Record<string, MarketSignal>

const PLATFORM_LABEL: Record<'polymarket' | 'kalshi', string> = {
polymarket: 'Polymarket',
kalshi: 'Kalshi',
}

// Generic footnote for cards/points that surface a live signal.
const LIVE_SIGNAL_NOTE =
Expand All @@ -34,7 +42,13 @@ const FA_ICON: Record<FocusAreaKey, AreaIconType> = {
neurotech: 'brain',
}

export default function ImpactDashboard({ liveOutputs = {} }: { liveOutputs?: LiveOutputs }) {
export default function ImpactDashboard({
liveOutputs = {},
marketSignals = {},
}: {
liveOutputs?: LiveOutputs
marketSignals?: MarketSignals
}) {
const [filter, setFilter] = useState<FocusAreaKey>('digital-human-rights')
const [active, setActive] = useState<InflectionPoint | null>(null)

Expand Down Expand Up @@ -73,6 +87,7 @@ export default function ImpactDashboard({ liveOutputs = {} }: { liveOutputs?: Li
key={`${p.area}-${p.title}`}
point={p}
metrics={liveOutputs[p.title]}
signal={marketSignals[p.title]}
onOpen={() => setActive(p)}
/>
))}
Expand All @@ -86,6 +101,7 @@ export default function ImpactDashboard({ liveOutputs = {} }: { liveOutputs?: Li
<InflectionModal
point={active}
metrics={liveOutputs[active.title]}
signal={marketSignals[active.title]}
onClose={() => setActive(null)}
/>
)}
Expand Down Expand Up @@ -191,13 +207,39 @@ function RoleChips({ roles, eyebrow = true }: { roles: PLRole[]; eyebrow?: boole
)
}

/** Compact crowd-odds read for the FIELD axis on a card (non-interactive). */
function CrowdSignalInline({ signal }: { signal: MarketSignal }) {
if (signal.match === 'gap') {
return (
<div className="mt-2.5 flex items-center gap-1.5 text-[11px] text-gray-400">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-gray-300" />
No market yet — unpriced bet
</div>
)
}
const pct = signal.prob != null ? Math.round(signal.prob * 100) : null
return (
<div className="mt-2.5 flex items-center gap-2 text-[11px]">
<span className="text-gray-400">
Crowd{signal.platform ? ` · ${PLATFORM_LABEL[signal.platform]}` : ''}
</span>
<span className="truncate text-gray-400">{signal.question}</span>
<span className="ml-auto shrink-0 font-semibold tabular-nums" style={{ color: FIELD_COLOR }}>
{pct != null ? `${pct}%` : 'live'}
</span>
</div>
)
}

function InflectionCard({
point,
metrics,
signal,
onOpen,
}: {
point: InflectionPoint
metrics?: LiveMetric[]
signal?: MarketSignal
onOpen: () => void
}) {
const fa = FOCUS_AREAS.find((f) => f.key === point.area)!
Expand Down Expand Up @@ -242,6 +284,7 @@ function InflectionCard({
<span className="ml-auto text-[11px] font-medium" style={{ color: FIELD_COLOR }}>{stageLabel}</span>
</div>
<FieldMeter status={point.status} />
{signal && <CrowdSignalInline signal={signal} />}
</div>

{/* OUR HAND — did our work help (violet) */}
Expand Down Expand Up @@ -281,13 +324,63 @@ function InflectionCard({
)
}

/** Compact USD, e.g. $15k, $1.2M. */
function formatUSD(n: number): string {
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
if (n >= 1_000) return `$${Math.round(n / 1_000)}k`
return `$${Math.round(n)}`
}

/** Crowd-forecast row for the modal's horizontal Live-signal band (with market link). */
function CrowdForecast({ signal, divider = false }: { signal: MarketSignal; divider?: boolean }) {
const pct = signal.prob != null ? Math.round(signal.prob * 100) : null
return (
<div className={divider ? 'mt-3 border-t border-gray-100 pt-3' : ''}>
<div className="mb-1 flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-gray-400">
Crowd forecast
</span>
{signal.platform && (
<span className="rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] font-medium text-gray-500">
{PLATFORM_LABEL[signal.platform]}
{signal.viaFallback ? ' (fallback)' : ''}
</span>
)}
{signal.volume != null && (
<span className="text-[11px] tabular-nums text-gray-400" title="Total money traded through this market">
{formatUSD(signal.volume)} at stake
</span>
)}
<span className="ml-auto text-2xl font-semibold tabular-nums" style={{ color: FIELD_COLOR }}>
{pct != null ? `${pct}%` : '—'}
</span>
</div>
{signal.url && (
<a
href={signal.url}
target="_blank"
rel="noopener noreferrer"
className="block text-sm text-gray-700 hover:underline"
>
{signal.question}
</a>
)}
<p className="mt-2 text-[11px] leading-relaxed text-gray-400">
{signal.note} Independent estimate on the field axis — not PL contribution, not a settled outcome.
</p>
</div>
)
}

function InflectionModal({
point,
metrics,
signal,
onClose,
}: {
point: InflectionPoint
metrics?: LiveMetric[]
signal?: MarketSignal
onClose: () => void
}) {
const fa = FOCUS_AREAS.find((f) => f.key === point.area)!
Expand Down Expand Up @@ -396,42 +489,60 @@ function InflectionModal({
</div>
</div>

{/* Live evidence (Q3 only — never a Q2 reading) */}
{/* Full-width band: live signal (when present) + latest insights for the area */}
{/* Full-width Live-signal band: PL-backed live outputs (Q3) + crowd forecast (field axis). */}
<div className="mt-6 border-t border-gray-100 pt-5">
{point.liveEvidence && (
<a
href={point.liveEvidence.href}
className="mb-4 block rounded-xl border border-gray-200 bg-white p-4 transition-colors hover:border-blue/40 no-underline"
>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-400">
Live signal
</div>
<div className="flex items-center gap-2 text-sm font-medium text-black">
{(point.liveEvidence || (signal && signal.match !== 'gap')) && (
<div className="mb-4 rounded-xl border border-gray-200 bg-white p-4">
<div className="mb-3 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-gray-400">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full" style={{ backgroundColor: `${LIVE_COLOR}99` }} />
<span className="relative inline-flex h-2 w-2 rounded-full" style={{ backgroundColor: LIVE_COLOR }} />
</span>
{point.liveEvidence.label}
<svg className="ml-auto h-4 w-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
Live signal
</div>
{metrics && metrics.length > 0 && (
<div className="mt-3 flex flex-wrap gap-x-6 gap-y-2">
{metrics.map((m) => (
<span key={m.label} className="flex items-baseline gap-1.5">
<span className="text-lg font-semibold text-black">{m.value}</span>
<span className="text-xs text-gray-500">{m.label}</span>
</span>
))}
</div>

{point.liveEvidence && (
<a
href={point.liveEvidence.href}
className="-m-1 block rounded-lg p-1 no-underline transition-colors hover:bg-gray-50"
>
<div className="flex items-center gap-2 text-sm font-medium text-black">
{point.liveEvidence.label}
<svg className="ml-auto h-4 w-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
{metrics && metrics.length > 0 && (
<div className="mt-3 flex flex-wrap gap-x-6 gap-y-2">
{metrics.map((m) => (
<span key={m.label} className="flex items-baseline gap-1.5">
<span className="text-lg font-semibold text-black">{m.value}</span>
<span className="text-xs text-gray-500">{m.label}</span>
</span>
))}
</div>
)}
<p className="mt-3 text-xs leading-relaxed text-gray-500">
{LIVE_SIGNAL_NOTE}
</p>
</a>
)}
<p className="mt-3 text-xs leading-relaxed text-gray-500">
{LIVE_SIGNAL_NOTE}
</p>
</a>

{signal && signal.match !== 'gap' && (
<CrowdForecast signal={signal} divider={!!point.liveEvidence} />
)}
</div>
)}

{signal && signal.match === 'gap' && (
<div className="mb-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 px-4 py-3">
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400">
Crowd forecast
</div>
<p className="text-sm leading-relaxed text-gray-500">{signal.note}</p>
</div>
)}

<a
href={`/insights/?area=${point.area}`}
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue hover:underline"
Expand Down
Loading