diff --git a/src/components/ProposalSections.tsx b/src/components/ProposalSections.tsx index bde42d7..a8def44 100644 --- a/src/components/ProposalSections.tsx +++ b/src/components/ProposalSections.tsx @@ -9,6 +9,7 @@ import { StatTile } from "@/components/StatTile"; import { Surface } from "@/components/Surface"; import { TitledSurface } from "@/components/TitledSurface"; import { formatDateTime } from "@/lib/dateTime"; +import { Link } from "react-router"; export type ProposalSummaryStat = { label: string; @@ -41,6 +42,13 @@ export type ProposalTimelineItem = { title: string; detail?: string; actor?: string; + snapshot?: { + fromStage: "pool" | "vote" | "build"; + toStage: "vote" | "build" | "passed" | "failed"; + reason?: string; + milestoneIndex?: number | null; + metrics: Array<{ label: string; value: string }>; + }; }; type ProposalSummaryCardProps = { @@ -228,13 +236,29 @@ export function ProposalInvisionInsightCard({ type ProposalTimelineCardProps = { items: ProposalTimelineItem[]; + proposalId?: string; }; function isLikelyAddress(value: string): boolean { return /^[a-z0-9]{6,}$/i.test(value) && value.length >= 20; } -export function ProposalTimelineCard({ items }: ProposalTimelineCardProps) { +function snapshotStageHref( + proposalId: string, + stage: "pool" | "vote" | "build", +): string | null { + if (stage === "pool") + return `/app/proposals/${proposalId}/pp?snapshotStage=pool`; + if (stage === "vote") + return `/app/proposals/${proposalId}/chamber?snapshotStage=vote`; + // `build` stage can become unavailable after terminal transition. + return null; +} + +export function ProposalTimelineCard({ + items, + proposalId, +}: ProposalTimelineCardProps) { return (

Timeline

@@ -271,6 +295,49 @@ export function ProposalTimelineCard({ items }: ProposalTimelineCardProps) { )}

) : null} + {item.snapshot ? ( +
+

+ Stage transition: {item.snapshot.fromStage} →{" "} + {item.snapshot.toStage} +

+ {item.snapshot.reason ? ( +

{item.snapshot.reason}

+ ) : null} + {item.snapshot.metrics.length > 0 ? ( + + ) : null} + {proposalId + ? (() => { + const href = snapshotStageHref( + proposalId, + item.snapshot.fromStage, + ); + if (!href) return null; + return ( + + Open {item.snapshot.fromStage} snapshot + + ); + })() + : null} +
+ ) : null} ))} {items.length === 0 && ( diff --git a/src/data/inlineHelp.ts b/src/data/inlineHelp.ts index 5ecb8e8..e084724 100644 --- a/src/data/inlineHelp.ts +++ b/src/data/inlineHelp.ts @@ -3,7 +3,7 @@ export type InlineHelpRegistry = Record>; export const inlineHelp: InlineHelpRegistry = { chambers: { metrics: - "A quick snapshot of chamber activity for this era (mock data) to help you spot where attention is needed.", + "A quick snapshot of chamber activity for this era to help you spot where attention is needed.", filters: "Use filters to find chambers with active proposal pools, votes, or Formation work.", cards: diff --git a/src/data/pageHints.ts b/src/data/pageHints.ts index 6a5060f..905b05e 100644 --- a/src/data/pageHints.ts +++ b/src/data/pageHints.ts @@ -263,7 +263,7 @@ export const pageHints: Record = { items: [ "Review case context and filings.", "See jury composition and timeline; track status badges.", - "Submit statements or view decisions (UI placeholder actions).", + "Submit statements or view decisions based on your current role and permissions.", ], }, ], diff --git a/src/data/vortexopedia.ts b/src/data/vortexopedia.ts index 5bbbe98..a4b35d5 100644 --- a/src/data/vortexopedia.ts +++ b/src/data/vortexopedia.ts @@ -762,7 +762,7 @@ export const vortexopediaTerms: VortexopediaTerm[] = [ "A cognitocratic system contains multiple specialization chambers, so CM/LCM from different chambers cannot be treated as equal by default.", "A CM of 5 in one chamber can be meaningfully different from a CM of 5 in another depending on what the system values at that time.", "The chamber multiplier defines these proportions between chambers so contributions can be normalized for aggregation.", - "In this demo, the multiplier is set collectively by cognitocrats who have not received LCM in that chamber (average of their inputs).", + "The multiplier is set collectively by cognitocrats who have not received LCM in that chamber (average of their inputs).", ], tags: ["cm", "multiplier", "chamber", "weighting"], related: ["cognitocratic_measure", "lcm", "mcm", "acm"], diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index dcf3613..06b8df9 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -26,6 +26,7 @@ import type { GetProposalTimelineResponse, HumanNodeProfileDto, ProposalDraftDetailDto, + ProposalStatusDto, PoolProposalPageDto, } from "@/types/api"; @@ -279,6 +280,12 @@ export async function apiProposalTimeline( ); } +export async function apiProposalStatus( + id: string, +): Promise { + return await apiGet(`/api/proposals/${id}/status`); +} + export type PoolVoteDirection = "up" | "down"; export async function apiPoolVote(input: { diff --git a/src/lib/dateTime.ts b/src/lib/dateTime.ts index 97466ce..e82a5da 100644 --- a/src/lib/dateTime.ts +++ b/src/lib/dateTime.ts @@ -27,6 +27,14 @@ function parseDate(value: string | number | Date): Date | null { return Number.isNaN(parsed.getTime()) ? null : parsed; } +export function toTimestampMs( + value: string | number | Date, + fallback = 0, +): number { + const parsed = parseDate(value); + return parsed ? parsed.getTime() : fallback; +} + export function getStoredDateFormat(): DateFormat { try { const raw = localStorage.getItem(DATE_FORMAT_KEY); diff --git a/src/lib/errorFormatting.ts b/src/lib/errorFormatting.ts new file mode 100644 index 0000000..5f4bbe3 --- /dev/null +++ b/src/lib/errorFormatting.ts @@ -0,0 +1,10 @@ +export function formatLoadError( + error: string | null | undefined, + fallback = "Request failed", +): string { + const raw = (error ?? "").trim(); + if (!raw) return fallback; + const stripped = raw.replace(/^HTTP\s+\d+\s*:\s*/i, "").trim(); + if (stripped) return stripped; + return fallback; +} diff --git a/src/pages/Guide.tsx b/src/pages/Guide.tsx index ebdad3f..c905f1b 100644 --- a/src/pages/Guide.tsx +++ b/src/pages/Guide.tsx @@ -269,8 +269,8 @@ const Guide: React.FC = () => { Vortex Guide

- A short, human-readable map of what you’re seeing in this demo, why - Vortex exists, and how each page works. + A short, human-readable map of what you’re seeing in the simulator, + why Vortex exists, and how each page works.

@@ -325,8 +325,9 @@ const Guide: React.FC = () => { simplistic voting surface.

- This repo is a demo mockup of that experience. - Numbers, identities, and statuses are illustrative. + This repo is a living simulator UI of that + experience. Data and governance state come from the current + simulation backend.

@@ -401,8 +402,8 @@ const Guide: React.FC = () => { > Proposal Creation {" "} - is a multi-step wizard in this demo. Steps are navigable so you - can explore the structure even if you don’t fill everything in. + is a multi-step wizard. Steps are navigable so you can explore + structure and submit complete proposals.

Drafts:{" "} @@ -521,8 +522,7 @@ const Guide: React.FC = () => { >

Invision is where governance becomes measurable: it summarizes - behavior and delivery patterns into scannable insights (still a - mock in this repo). + behavior and delivery patterns into scannable insights.

In the app you’ll see “Invision insight” snippets attached to @@ -534,7 +534,7 @@ const Guide: React.FC = () => {

Courts is the judicial layer. Cases are structured around a @@ -546,8 +546,8 @@ const Guide: React.FC = () => { Statuses (jury / live / ended) reflect the state of the case.

  • - Verdict buttons are present as a UI mock to show how an action - might look. + Verdict actions appear based on case stage and participant + permissions.
  • @@ -555,7 +555,7 @@ const Guide: React.FC = () => {

    The CM Panel is a specialized admin-style surface. In a real @@ -566,7 +566,7 @@ const Guide: React.FC = () => {

    Profile is your personal view. Settings is where future @@ -627,8 +627,8 @@ const Guide: React.FC = () => {

    - This guide describes the demo mockups shipped in this repo and will - evolve as the community tests and gives feedback. + This guide describes the live simulator surfaces and will evolve as + the community tests and gives feedback. diff --git a/src/pages/MyGovernance.tsx b/src/pages/MyGovernance.tsx index 972dcdd..84e08f9 100644 --- a/src/pages/MyGovernance.tsx +++ b/src/pages/MyGovernance.tsx @@ -23,6 +23,8 @@ import { apiCmMe, apiMyGovernance, } from "@/lib/apiClient"; +import { formatLoadError } from "@/lib/errorFormatting"; +import { toTimestampMs } from "@/lib/dateTime"; import type { ChamberDto, CmSummaryDto, @@ -205,7 +207,7 @@ const MyGovernance: React.FC = () => { const timeLeftValue = useMemo(() => { const targetMs = clock?.nextEraAt - ? new Date(clock.nextEraAt).getTime() + ? toTimestampMs(clock.nextEraAt, NaN) : NaN; if (Number.isFinite(targetMs)) { return formatDayHourMinute(targetMs, nowMs); @@ -255,7 +257,9 @@ const MyGovernance: React.FC = () => { loadError ? "text-destructive" : undefined, )} > - {loadError ? `My governance unavailable: ${loadError}` : "Loading…"} + {loadError + ? `My governance unavailable: ${formatLoadError(loadError)}` + : "Loading…"} ) : null} diff --git a/src/pages/chambers/Chamber.tsx b/src/pages/chambers/Chamber.tsx index 579f074..8d8056a 100644 --- a/src/pages/chambers/Chamber.tsx +++ b/src/pages/chambers/Chamber.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useParams, Link } from "react-router"; +import { useParams, Link, useSearchParams } from "react-router"; import { Button } from "@/components/primitives/button"; import { @@ -43,11 +43,13 @@ import { apiMyGovernance, } from "@/lib/apiClient"; import { formatDate, formatDateTime } from "@/lib/dateTime"; +import { formatLoadError } from "@/lib/errorFormatting"; import { NoDataYetBar } from "@/components/NoDataYetBar"; import { useAuth } from "@/app/auth/AuthContext"; const Chamber: React.FC = () => { const { id } = useParams(); + const [searchParams] = useSearchParams(); const { address } = useAuth(); const [chamberTitle, setChamberTitle] = useState(() => id ? id.replace(/-/g, " ") : "Chamber", @@ -91,6 +93,7 @@ const Chamber: React.FC = () => { const [stageFilter, setStageFilter] = useState("upcoming"); const [governorSearch, setGovernorSearch] = useState(""); + const requestedThreadId = searchParams.get("thread")?.trim() || null; useEffect(() => { if (!id) return; @@ -547,6 +550,12 @@ const Chamber: React.FC = () => { [activeThread, canWrite, id, threadReplyBody], ); + useEffect(() => { + if (!requestedThreadId) return; + if (activeThread?.thread.id === requestedThreadId) return; + void handleThreadSelect(requestedThreadId); + }, [activeThread?.thread.id, handleThreadSelect, requestedThreadId]); + const handleChatSend = useCallback( async (event: React.FormEvent) => { event.preventDefault(); @@ -594,7 +603,7 @@ const Chamber: React.FC = () => { shadow="tile" className="px-5 py-4 text-sm text-destructive" > - Chamber unavailable: {loadError} + Chamber unavailable: {formatLoadError(loadError)} ) : null} @@ -799,7 +808,9 @@ const Chamber: React.FC = () => { borderStyle="dashed" className="px-4 py-4 text-center text-sm text-muted" > - {cmError ? `CM summary unavailable: ${cmError}` : "Loading CM…"} + {cmError + ? `CM summary unavailable: ${formatLoadError(cmError)}` + : "Loading CM…"} )} @@ -1023,7 +1034,9 @@ const Chamber: React.FC = () => { className="min-h-[110px] w-full resize-y rounded-xl border border-border bg-panel-alt px-3 py-2 text-sm text-text shadow-[var(--shadow-control)] focus-visible:ring-2 focus-visible:ring-[color:var(--primary-dim)] focus-visible:outline-none" /> {threadError ? ( -

    {threadError}

    +

    + {formatLoadError(threadError)} +

    ) : null}
    {chatError ? ( -

    {chatError}

    +

    + {formatLoadError(chatError)} +

    ) : null} {chatSignalError ? ( -

    {chatSignalError}

    +

    + {formatLoadError(chatSignalError)} +

    ) : null} {!canWrite ? (

    diff --git a/src/pages/chambers/Chambers.tsx b/src/pages/chambers/Chambers.tsx index d33ede0..b307ef5 100644 --- a/src/pages/chambers/Chambers.tsx +++ b/src/pages/chambers/Chambers.tsx @@ -14,6 +14,7 @@ import { InlineHelp } from "@/components/InlineHelp"; import { NoDataYetBar } from "@/components/NoDataYetBar"; import { apiChambers, apiClock, apiHumans } from "@/lib/apiClient"; import { getChamberNumericStats } from "@/lib/dtoParsers"; +import { formatLoadError } from "@/lib/errorFormatting"; import type { ChamberDto } from "@/types/api"; import { Surface } from "@/components/Surface"; @@ -101,9 +102,7 @@ const Chambers: React.FC = () => { chamber.stats.lcm.toLowerCase().includes(term) || String(chamber.multiplier).toLowerCase().includes(term); const matchesPipeline = - pipelineFilter === "any" || - chamber.pipeline[pipelineFilter] > 0 || - pipelineFilter === "build"; + pipelineFilter === "any" || chamber.pipeline[pipelineFilter] > 0; return matchesTerm && matchesPipeline; }) .sort((a, b) => { @@ -189,7 +188,7 @@ const Chambers: React.FC = () => { shadow="tile" className="px-5 py-4 text-sm text-destructive" > - Chambers unavailable: {loadError} + Chambers unavailable: {formatLoadError(loadError)} ) : null} diff --git a/src/pages/cm/CMPanel.tsx b/src/pages/cm/CMPanel.tsx index 2b3885e..5b61b60 100644 --- a/src/pages/cm/CMPanel.tsx +++ b/src/pages/cm/CMPanel.tsx @@ -17,6 +17,7 @@ import { apiChamberMultiplierSubmit, apiMyGovernance, } from "@/lib/apiClient"; +import { formatLoadError } from "@/lib/errorFormatting"; import type { ChamberDto } from "@/types/api"; const CMPanel: React.FC = () => { @@ -153,7 +154,7 @@ const CMPanel: React.FC = () => { {loadError ? ( - CM panel unavailable: {loadError} + CM panel unavailable: {formatLoadError(loadError)} ) : null} {chambers === null && !loadError ? ( @@ -166,7 +167,7 @@ const CMPanel: React.FC = () => { ) : null} {submitError ? ( - CM submission failed: {submitError} + CM submission failed: {formatLoadError(submitError)} ) : null} diff --git a/src/pages/courts/Courtroom.tsx b/src/pages/courts/Courtroom.tsx index 0f16fc1..77ef4b6 100644 --- a/src/pages/courts/Courtroom.tsx +++ b/src/pages/courts/Courtroom.tsx @@ -20,6 +20,7 @@ import { apiHumans, } from "@/lib/apiClient"; import { formatDateTime } from "@/lib/dateTime"; +import { formatLoadError } from "@/lib/errorFormatting"; import type { CourtCaseDetailDto, HumanNodeDto } from "@/types/api"; const Courtroom: React.FC = () => { @@ -133,7 +134,7 @@ const Courtroom: React.FC = () => { ) : null} {loadError ? ( - Courtroom unavailable: {loadError} + Courtroom unavailable: {formatLoadError(loadError)} ) : null} @@ -220,8 +221,8 @@ const Courtroom: React.FC = () => { />

    {actionError ? ( -

    - {actionError} +

    + {formatLoadError(actionError)}

    ) : null} {!votingEnabled ? ( diff --git a/src/pages/courts/Courts.tsx b/src/pages/courts/Courts.tsx index 14fa444..4247bc4 100644 --- a/src/pages/courts/Courts.tsx +++ b/src/pages/courts/Courts.tsx @@ -13,7 +13,8 @@ import { CourtStatusBadge } from "@/components/CourtStatusBadge"; import { PageHint } from "@/components/PageHint"; import { NoDataYetBar } from "@/components/NoDataYetBar"; import { apiCourts } from "@/lib/apiClient"; -import { formatDateTime } from "@/lib/dateTime"; +import { formatDateTime, toTimestampMs } from "@/lib/dateTime"; +import { formatLoadError } from "@/lib/errorFormatting"; import type { CourtCaseDto, CourtCaseStatusDto } from "@/types/api"; const Courts: React.FC = () => { @@ -25,7 +26,6 @@ const Courts: React.FC = () => { sortBy: "recent" | "reports"; }>({ statusFilter: "any", sortBy: "recent" }); const { statusFilter, sortBy } = filters; - useEffect(() => { let active = true; (async () => { @@ -61,8 +61,7 @@ const Courts: React.FC = () => { .sort((a, b) => { if (sortBy === "reports") return b.reports - a.reports; return ( - new Date(b.opened.split("/").reverse().join("-")).getTime() - - new Date(a.opened.split("/").reverse().join("-")).getTime() + toTimestampMs(b.opened ?? "", 0) - toTimestampMs(a.opened ?? "", 0) ); }); }, [cases, search, statusFilter, sortBy]); @@ -99,7 +98,8 @@ const Courts: React.FC = () => { ) : null} {loadError ? ( - Courts unavailable: {loadError} + Courts unavailable:{" "} + {formatLoadError(loadError, "Failed to load courts.")} ) : null} @@ -157,7 +157,10 @@ const Courts: React.FC = () => {

    - Opened {formatDateTime(courtCase.opened)} + Opened{" "} + {courtCase.opened + ? formatDateTime(courtCase.opened) + : "—"}

    diff --git a/src/pages/factions/Faction.tsx b/src/pages/factions/Faction.tsx index 26b944c..80a30c1 100644 --- a/src/pages/factions/Faction.tsx +++ b/src/pages/factions/Faction.tsx @@ -30,6 +30,7 @@ import { getApiErrorPayload, } from "@/lib/apiClient"; import { formatDateTime } from "@/lib/dateTime"; +import { formatLoadError } from "@/lib/errorFormatting"; import type { FactionDto } from "@/types/api"; function normalizeAddress(value: string): string { @@ -162,7 +163,9 @@ const Faction: React.FC = () => {

    Faction not found

    {loadError ? ( -

    {loadError}

    +

    + {formatLoadError(loadError)} +

    ) : null}
    - {isFounderAdmin ? ( + {canModerate ? ( { return (

    Channel not found

    - {error ?

    {error}

    : null} + {error ? ( +

    {formatLoadError(error)}

    + ) : null}
    - {loadError ? `Draft unavailable: ${loadError}` : "Loading draft…"} + {loadError + ? `Draft unavailable: ${formatLoadError(loadError, "Failed to load draft.")}` + : "Loading draft…"} ); @@ -121,7 +138,9 @@ const ProposalDraft: React.FC = () => { setSubmitting(true); try { const res = await apiProposalSubmitToPool({ draftId: id }); - window.location.href = `/app/proposals/${res.proposalId}/pp`; + navigate(`/app/proposals/${res.proposalId}/pp`, { + replace: true, + }); } catch (error) { setSubmitError(formatProposalSubmitError(error)); } finally { @@ -135,7 +154,7 @@ const ProposalDraft: React.FC = () => { {submitError ? ( - Submit failed: {submitError} + Submit failed: {formatLoadError(submitError)} ) : null} diff --git a/src/pages/proposals/ProposalDrafts.tsx b/src/pages/proposals/ProposalDrafts.tsx index 30970cb..b3ff9ea 100644 --- a/src/pages/proposals/ProposalDrafts.tsx +++ b/src/pages/proposals/ProposalDrafts.tsx @@ -9,6 +9,7 @@ import { Kicker } from "@/components/Kicker"; import { NoDataYetBar } from "@/components/NoDataYetBar"; import { apiProposalDrafts } from "@/lib/apiClient"; import { formatDateTime } from "@/lib/dateTime"; +import { formatLoadError } from "@/lib/errorFormatting"; import type { ProposalDraftListItemDto } from "@/types/api"; const ProposalDrafts: React.FC = () => { @@ -114,7 +115,7 @@ const ProposalDrafts: React.FC = () => { ) : null} {loadError ? ( - Drafts unavailable: {loadError} + Drafts unavailable: {formatLoadError(loadError)} ) : null} {drafts !== null && drafts.length === 0 && !loadError ? ( diff --git a/src/pages/proposals/ProposalFinished.tsx b/src/pages/proposals/ProposalFinished.tsx index c310af1..a7ceabb 100644 --- a/src/pages/proposals/ProposalFinished.tsx +++ b/src/pages/proposals/ProposalFinished.tsx @@ -14,6 +14,11 @@ import type { FormationProposalPageDto, ProposalTimelineItemDto, } from "@/types/api"; +import { + useProposalStageSync, + useProposalTransitionNotice, +} from "./useProposalStageSync"; +import { formatLoadError } from "@/lib/errorFormatting"; const ProposalFinished: React.FC = () => { const { id } = useParams(); @@ -21,7 +26,8 @@ const ProposalFinished: React.FC = () => { const [loadError, setLoadError] = useState(null); const [timeline, setTimeline] = useState([]); const [timelineError, setTimelineError] = useState(null); - + useProposalStageSync(id); + const transitionNotice = useProposalTransitionNotice(); useEffect(() => { if (!id) return; let active = true; @@ -63,6 +69,16 @@ const ProposalFinished: React.FC = () => { return (
    + {transitionNotice ? ( + + {transitionNotice} + + ) : null} { className="px-5 py-4 text-sm text-muted" > {loadError - ? `Proposal unavailable: ${loadError}` + ? `Proposal unavailable: ${formatLoadError(loadError, "Failed to load proposal.")}` : "Loading proposal…"}
    @@ -82,9 +98,19 @@ const ProposalFinished: React.FC = () => { return (
    + {transitionNotice ? ( + + {transitionNotice} + + ) : null} @@ -153,10 +179,10 @@ const ProposalFinished: React.FC = () => { shadow="tile" className="px-5 py-4 text-sm text-muted" > - Timeline unavailable: {timelineError} + Timeline unavailable: {formatLoadError(timelineError)} ) : ( - + )}
    ); diff --git a/src/pages/proposals/ProposalFormation.tsx b/src/pages/proposals/ProposalFormation.tsx index 65eeef2..d49a666 100644 --- a/src/pages/proposals/ProposalFormation.tsx +++ b/src/pages/proposals/ProposalFormation.tsx @@ -17,11 +17,16 @@ import { apiProposalFormationPage, apiProposalTimeline, } from "@/lib/apiClient"; +import { formatLoadError } from "@/lib/errorFormatting"; import { useAuth } from "@/app/auth/AuthContext"; import type { FormationProposalPageDto, ProposalTimelineItemDto, } from "@/types/api"; +import { + useProposalStageSync, + useProposalTransitionNotice, +} from "./useProposalStageSync"; const ProposalFormation: React.FC = () => { const { id } = useParams(); @@ -33,7 +38,8 @@ const ProposalFormation: React.FC = () => { const [timeline, setTimeline] = useState([]); const [timelineError, setTimelineError] = useState(null); const auth = useAuth(); - + const syncProposalStage = useProposalStageSync(id); + const transitionNotice = useProposalTransitionNotice(); useEffect(() => { if (!id) return; let active = true; @@ -75,6 +81,16 @@ const ProposalFormation: React.FC = () => { return (
    + {transitionNotice ? ( + + {transitionNotice} + + ) : null} { className="px-5 py-4 text-sm text-muted" > {loadError - ? `Proposal unavailable: ${loadError}` + ? `Proposal unavailable: ${formatLoadError(loadError, "Failed to load proposal.")}` : "Loading proposal…"}
    @@ -90,10 +106,12 @@ const ProposalFormation: React.FC = () => { } const parseRatio = (value: string): { filled: number; total: number } => { - const parts = value.split("/").map((p) => p.trim()); - if (parts.length !== 2) return { filled: 0, total: 0 }; - const filled = Number(parts[0]); - const total = Number(parts[1]); + const matches = value.match(/\d+/g) ?? []; + const filledRaw = matches[0]; + const totalRaw = matches[1]; + if (!filledRaw || !totalRaw) return { filled: 0, total: 0 }; + const filled = Number.parseInt(filledRaw, 10); + const total = Number.parseInt(totalRaw, 10); return { filled: Number.isFinite(filled) ? filled : 0, total: Number.isFinite(total) ? total : 0, @@ -138,6 +156,8 @@ const ProposalFormation: React.FC = () => { setActionBusy(true); try { await fn(); + const redirected = await syncProposalStage(); + if (redirected) return; if (id) { const next = await apiProposalFormationPage(id); setProject(next); @@ -152,6 +172,16 @@ const ProposalFormation: React.FC = () => { return (
    + {transitionNotice ? ( + + {transitionNotice} + + ) : null} { ) : null} {actionError ? ( -

    - {actionError} +

    + {formatLoadError(actionError)}

    ) : null} @@ -310,10 +340,10 @@ const ProposalFormation: React.FC = () => { shadow="tile" className="px-5 py-4 text-sm text-muted" > - Timeline unavailable: {timelineError} + Timeline unavailable: {formatLoadError(timelineError)} ) : ( - + )}
    ); diff --git a/src/pages/proposals/ProposalPP.tsx b/src/pages/proposals/ProposalPP.tsx index 876f8a9..a930454 100644 --- a/src/pages/proposals/ProposalPP.tsx +++ b/src/pages/proposals/ProposalPP.tsx @@ -19,8 +19,13 @@ import { apiProposalTimeline, getApiErrorPayload, } from "@/lib/apiClient"; +import { formatLoadError } from "@/lib/errorFormatting"; import type { PoolProposalPageDto, ProposalTimelineItemDto } from "@/types/api"; import { useAuth } from "@/app/auth/AuthContext"; +import { + useProposalStageSync, + useProposalTransitionNotice, +} from "./useProposalStageSync"; const ProposalPP: React.FC = () => { const { id } = useParams(); @@ -36,6 +41,8 @@ const ProposalPP: React.FC = () => { const [timeline, setTimeline] = useState([]); const [timelineError, setTimelineError] = useState(null); const auth = useAuth(); + const syncProposalStage = useProposalStageSync(id); + const transitionNotice = useProposalTransitionNotice(); const formatPoolVoteError = (error: unknown): string => { const payloadMessage = getApiErrorPayload(error)?.error?.message; @@ -87,6 +94,16 @@ const ProposalPP: React.FC = () => { return (
    + {transitionNotice ? ( + + {transitionNotice} + + ) : null} { className="px-5 py-4 text-sm text-muted" > {loadError - ? `Proposal unavailable: ${loadError}` + ? `Proposal unavailable: ${formatLoadError(loadError, "Failed to load proposal.")}` : "Loading proposal…"}
    @@ -128,6 +145,16 @@ const ProposalPP: React.FC = () => { return (
    + {transitionNotice ? ( + + {transitionNotice} + + ) : null}
    { direction: pendingAction === "upvote" ? "up" : "down", idempotencyKey: crypto.randomUUID(), }); + const redirected = await syncProposalStage(); + if (redirected) { + setShowRules(false); + return; + } const next = await apiProposalPoolPage(id); setProposal(next); setShowRules(false); @@ -340,6 +372,7 @@ const ProposalPP: React.FC = () => { setVoteError(formatPoolVoteError(error)); } finally { setVoteSubmitting(false); + void syncProposalStage(); } }} > @@ -353,7 +386,9 @@ const ProposalPP: React.FC = () => {
    {voteError ? ( -

    {voteError}

    +

    + {formatLoadError(voteError)} +

    ) : null} @@ -365,10 +400,10 @@ const ProposalPP: React.FC = () => { shadow="tile" className="px-5 py-4 text-sm text-muted" > - Timeline unavailable: {timelineError} + Timeline unavailable: {formatLoadError(timelineError)} ) : ( - + )}
    ); diff --git a/src/pages/proposals/Proposals.tsx b/src/pages/proposals/Proposals.tsx index 0dee87a..fe05467 100644 --- a/src/pages/proposals/Proposals.tsx +++ b/src/pages/proposals/Proposals.tsx @@ -16,6 +16,8 @@ import { Surface } from "@/components/Surface"; import { NoDataYetBar } from "@/components/NoDataYetBar"; import { HintLabel } from "@/components/Hint"; import { getFormationProgress } from "@/lib/dtoParsers"; +import { formatLoadError } from "@/lib/errorFormatting"; +import { toTimestampMs } from "@/lib/dateTime"; import { apiProposalChamberPage, apiProposalFormationPage, @@ -29,6 +31,19 @@ import type { ProposalListItemDto, } from "@/types/api"; +function parseRatioPair(value: string): { left: number; right: number } { + const matches = value.match(/\d+/g) ?? []; + const leftRaw = matches[0]; + const rightRaw = matches[1]; + if (!leftRaw || !rightRaw) return { left: 0, right: 0 }; + const left = Number.parseInt(leftRaw, 10); + const right = Number.parseInt(rightRaw, 10); + return { + left: Number.isFinite(left) ? left : 0, + right: Number.isFinite(right) ? right : 0, + }; +} + const Proposals: React.FC = () => { const [proposalData, setProposalData] = useState< ProposalListItemDto[] | null @@ -140,10 +155,10 @@ const Proposals: React.FC = () => { }) .sort((a, b) => { if (sortBy === "Newest") { - return new Date(b.date).getTime() - new Date(a.date).getTime(); + return toTimestampMs(b.date, -1) - toTimestampMs(a.date, -1); } if (sortBy === "Oldest") { - return new Date(a.date).getTime() - new Date(b.date).getTime(); + return toTimestampMs(a.date, -1) - toTimestampMs(b.date, -1); } if (sortBy === "Activity") { return b.activityScore - a.activityScore; @@ -258,7 +273,7 @@ const Proposals: React.FC = () => { shadow="tile" className="px-5 py-4 text-sm text-destructive" > - Proposals unavailable: {loadError} + Proposals unavailable: {formatLoadError(loadError)} ) : null} @@ -300,9 +315,8 @@ const Proposals: React.FC = () => { 1, poolPage.activeGovernors, ); - const [filledSlots, totalSlots] = poolPage.teamSlots - .split("/") - .map((v) => Number(v.trim())); + const { left: filledSlots, right: totalSlots } = + parseRatioPair(poolPage.teamSlots); const openSlots = Math.max(totalSlots - filledSlots, 0); const milestonesCount = Number(poolPage.milestones); const engaged = poolPage.upvotes + poolPage.downvotes; @@ -372,21 +386,18 @@ const Proposals: React.FC = () => { // Derive engaged from chamber vote totals to avoid mixing in // any pre-vote/pool counters. const engaged = totalVotes; - const quorumNeeded = Math.ceil( - activeGovernors * chamberPage.attentionQuorum, - ); + const quorumNeeded = chamberPage.quorumNeeded; const quorumPercent = Math.round( (engaged / activeGovernors) * 100, ); const quorumNeededPercent = Math.round( - chamberPage.attentionQuorum * 100, + (quorumNeeded / activeGovernors) * 100, ); const yesPercentOfQuorum = engaged > 0 ? Math.round((yesTotal / engaged) * 100) : 0; - const [filledSlots, totalSlots] = chamberPage.teamSlots - .split("/") - .map((v) => Number(v.trim())); + const { left: filledSlots, right: totalSlots } = + parseRatioPair(chamberPage.teamSlots); const openSlots = Math.max(totalSlots - filledSlots, 0); const meetsQuorum = engaged >= quorumNeeded; @@ -786,7 +797,9 @@ const Proposals: React.FC = () => { : proposal.stage === "vote" ? `/app/proposals/${proposal.id}/chamber` : proposal.stage === "passed" - ? `/app/proposals/${proposal.id}/chamber` + ? proposal.summaryPill === "Finished" + ? `/app/proposals/${proposal.id}/finished` + : `/app/proposals/${proposal.id}/chamber` : proposal.stage === "build" ? proposal.summaryPill === "Finished" ? `/app/proposals/${proposal.id}/finished` diff --git a/src/pages/proposals/proposalCreation/steps/BudgetStep.tsx b/src/pages/proposals/proposalCreation/steps/BudgetStep.tsx index fa147f2..3a07f04 100644 --- a/src/pages/proposals/proposalCreation/steps/BudgetStep.tsx +++ b/src/pages/proposals/proposalCreation/steps/BudgetStep.tsx @@ -57,7 +57,7 @@ export function BudgetStep(props: {

    {item.timeframe.trim().length > 0 ? item.timeframe - : "Timeline TBD"} + : "Timeline not specified"}

    diff --git a/src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx b/src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx index 7617461..18c33b4 100644 --- a/src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx +++ b/src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx @@ -678,7 +678,7 @@ export function EssentialsStep(props: { chamberId: "general", })); }} - placeholder={"5F...Alice\n5F...Bob"} + placeholder={"hm...\nhm..."} />
    ) : null} diff --git a/src/pages/proposals/proposalCreation/steps/ReviewStep.tsx b/src/pages/proposals/proposalCreation/steps/ReviewStep.tsx index d26ad72..be31a7e 100644 --- a/src/pages/proposals/proposalCreation/steps/ReviewStep.tsx +++ b/src/pages/proposals/proposalCreation/steps/ReviewStep.tsx @@ -2,6 +2,7 @@ import type React from "react"; import { Button } from "@/components/primitives/button"; import { Input } from "@/components/primitives/input"; import { Label } from "@/components/primitives/label"; +import { AddressInline } from "@/components/AddressInline"; import { SIM_AUTH_ENABLED } from "@/lib/featureFlags"; import { newId } from "../ids"; import type { ProposalDraftForm } from "../types"; @@ -24,6 +25,7 @@ export function ReviewStep(props: { draft: ProposalDraftForm; formationEligible?: boolean; mode: "project" | "system"; + proposerAddress: string | null; selectedChamber: ChamberDto | null; setDraft: React.Dispatch>; textareaClassName: string; @@ -35,6 +37,7 @@ export function ReviewStep(props: { draft, formationEligible, mode, + proposerAddress, selectedChamber, setDraft, textareaClassName, @@ -47,13 +50,17 @@ export function ReviewStep(props: { return (
    -

    Who (auto-filled)

    -
    +

    + Proposer (auto-filled) +

    +
    - Name: Humanode Governor -
    -
    - Handle: @governor_42 + Wallet:{" "} + {proposerAddress ? ( + + ) : ( + "—" + )}
    diff --git a/src/pages/proposals/useProposalStageSync.ts b/src/pages/proposals/useProposalStageSync.ts new file mode 100644 index 0000000..03fc386 --- /dev/null +++ b/src/pages/proposals/useProposalStageSync.ts @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useState } from "react"; +import type { NavigateFunction } from "react-router"; +import { useLocation, useNavigate } from "react-router"; + +import { apiProposalStatus } from "@/lib/apiClient"; +import type { ProposalStatusDto } from "@/types/api"; + +const PROPOSAL_STAGE_SYNC_INTERVAL_MS = 7000; +const STAGE_NOTICE_STORAGE_KEY = "vortex:proposal-stage-transition-notice"; + +type ProposalStageTransitionNotice = { + route: string; + message: string; +}; + +type SyncToCanonicalStageInput = { + proposalId: string; + currentPath: string; + currentSearch?: string; + navigate: NavigateFunction; + enforceVisibility?: boolean; +}; + +export function shouldNavigateToCanonicalRoute( + currentPath: string, + status: Pick, +): boolean { + return Boolean( + status.canonicalRoute && status.canonicalRoute !== currentPath, + ); +} + +function hasSnapshotRouteOverride(search?: string): boolean { + if (!search) return false; + const params = new URLSearchParams(search); + const stage = params.get("snapshotStage"); + return stage === "pool" || stage === "vote" || stage === "build"; +} + +export function formatProposalStageTransitionMessage( + status: Pick< + ProposalStatusDto, + "canonicalStage" | "redirectReason" | "pendingMilestoneIndex" + >, +): string { + if (status.redirectReason === "milestone_vote_open") { + return typeof status.pendingMilestoneIndex === "number" && + status.pendingMilestoneIndex > 0 + ? `Milestone M${status.pendingMilestoneIndex} entered chamber vote.` + : "Milestone entered chamber vote."; + } + if (status.redirectReason === "formation_completed") { + return "Project finished and moved to Finished."; + } + if (status.redirectReason === "formation_canceled") { + return "Project was canceled and moved to Finished."; + } + if (status.canonicalStage === "vote") + return "Proposal moved to Chamber vote."; + if (status.canonicalStage === "build") return "Proposal moved to Formation."; + if (status.canonicalStage === "passed") return "Proposal moved to Passed."; + if (status.canonicalStage === "failed") return "Proposal moved to Failed."; + return "Proposal stage updated."; +} + +function storeTransitionNotice(notice: ProposalStageTransitionNotice): void { + if (typeof window === "undefined") return; + try { + window.sessionStorage.setItem( + STAGE_NOTICE_STORAGE_KEY, + JSON.stringify(notice), + ); + } catch { + // noop + } +} + +function consumeTransitionNoticeForRoute(route: string): string | null { + if (typeof window === "undefined") return null; + try { + const raw = window.sessionStorage.getItem(STAGE_NOTICE_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as ProposalStageTransitionNotice | null; + window.sessionStorage.removeItem(STAGE_NOTICE_STORAGE_KEY); + if (!parsed || parsed.route !== route || !parsed.message) return null; + return parsed.message; + } catch { + return null; + } +} + +export function useProposalTransitionNotice(): string | null { + const location = useLocation(); + const [notice, setNotice] = useState(null); + + useEffect(() => { + setNotice(consumeTransitionNoticeForRoute(location.pathname)); + }, [location.pathname]); + + return notice; +} + +export async function syncToCanonicalProposalStage( + input: SyncToCanonicalStageInput, +): Promise { + if (hasSnapshotRouteOverride(input.currentSearch)) return false; + if ( + input.enforceVisibility && + typeof document !== "undefined" && + document.visibilityState !== "visible" + ) { + return false; + } + const status = await apiProposalStatus(input.proposalId); + if (!shouldNavigateToCanonicalRoute(input.currentPath, status)) return false; + storeTransitionNotice({ + route: status.canonicalRoute, + message: formatProposalStageTransitionMessage(status), + }); + input.navigate(status.canonicalRoute, { replace: true }); + return true; +} + +export function useProposalStageSync( + proposalId: string | undefined, +): () => Promise { + const location = useLocation(); + const navigate = useNavigate(); + + const syncNow = useCallback(async (): Promise => { + if (!proposalId) return false; + try { + return await syncToCanonicalProposalStage({ + proposalId, + currentPath: location.pathname, + currentSearch: location.search, + navigate, + enforceVisibility: false, + }); + } catch { + return false; + } + }, [location.pathname, location.search, navigate, proposalId]); + + useEffect(() => { + if (!proposalId) return; + let active = true; + + const poll = async () => { + if (!active) return; + try { + await syncToCanonicalProposalStage({ + proposalId, + currentPath: location.pathname, + currentSearch: location.search, + navigate, + enforceVisibility: true, + }); + } catch { + // Route-level loaders already surface user-facing errors. + } + }; + + void poll(); + const timer = window.setInterval(() => { + void poll(); + }, PROPOSAL_STAGE_SYNC_INTERVAL_MS); + + return () => { + active = false; + window.clearInterval(timer); + }; + }, [location.pathname, location.search, navigate, proposalId]); + + return syncNow; +} diff --git a/src/types/api.ts b/src/types/api.ts index f26ce97..f22f626 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -398,10 +398,32 @@ export type ProposalTimelineItemDto = { detail?: string; actor?: string; timestamp: string; + snapshot?: { + fromStage: "pool" | "vote" | "build"; + toStage: "vote" | "build" | "passed" | "failed"; + reason?: string; + milestoneIndex?: number | null; + metrics: Array<{ label: string; value: string }>; + }; }; export type GetProposalTimelineResponse = { items: ProposalTimelineItemDto[] }; +export type ProposalStatusDto = { + proposalId: string; + canonicalStage: ProposalStageDto; + canonicalRoute: string; + redirectReason?: string; + formationProjectState?: + | "active" + | "awaiting_milestone_vote" + | "canceled" + | "ready_to_finish" + | "completed"; + pendingMilestoneIndex?: number | null; + updatedAt: string; +}; + export type ProposalDraftListItemDto = { id: string; title: string; @@ -611,7 +633,7 @@ export type CourtCaseDto = { status: CourtCaseStatusDto; reports: number; juryIds: string[]; - opened: string; + opened: string | null; }; export type CourtCaseDetailDto = CourtCaseDto & { parties: { role: string; humanId: string; note?: string }[]; diff --git a/tests/api/api-client.test.js b/tests/api/api-client.test.js index cee4e1e..3ee8648 100644 --- a/tests/api/api-client.test.js +++ b/tests/api/api-client.test.js @@ -6,6 +6,7 @@ import { apiChamberCm, apiCmAddress, apiCmMe, + apiProposalStatus, } from "../../src/lib/apiClient.ts"; test("apiChamber requests the chamber endpoint with credentials", async () => { @@ -133,3 +134,34 @@ test("apiCmAddress requests the cm/:address endpoint", async () => { global.fetch = originalFetch; }); + +test("apiProposalStatus requests canonical proposal status endpoint", async () => { + const originalFetch = global.fetch; + const calls = []; + + global.fetch = async (input, init) => { + calls.push({ input, init }); + return new Response( + JSON.stringify({ + proposalId: "proposal-1", + canonicalStage: "vote", + canonicalRoute: "/app/proposals/proposal-1/chamber", + redirectReason: "milestone_vote_open", + pendingMilestoneIndex: 1, + updatedAt: new Date().toISOString(), + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ); + }; + + const res = await apiProposalStatus("proposal-1"); + assert.equal(res.proposalId, "proposal-1"); + assert.equal(res.canonicalStage, "vote"); + assert.equal(calls[0].input, "/api/proposals/proposal-1/status"); + assert.equal(calls[0].init.credentials, "include"); + + global.fetch = originalFetch; +}); diff --git a/tests/unit/proposal-stage-sync.test.ts b/tests/unit/proposal-stage-sync.test.ts new file mode 100644 index 0000000..8a6b7db --- /dev/null +++ b/tests/unit/proposal-stage-sync.test.ts @@ -0,0 +1,40 @@ +import { test } from "@rstest/core"; +import assert from "node:assert/strict"; + +import { + formatProposalStageTransitionMessage, + shouldNavigateToCanonicalRoute, +} from "../../src/pages/proposals/useProposalStageSync.ts"; + +test("shouldNavigateToCanonicalRoute prevents redirect loops on same path", () => { + assert.equal( + shouldNavigateToCanonicalRoute("/app/proposals/p-1/chamber", { + canonicalRoute: "/app/proposals/p-1/chamber", + }), + false, + ); + assert.equal( + shouldNavigateToCanonicalRoute("/app/proposals/p-1/pp", { + canonicalRoute: "/app/proposals/p-1/chamber", + }), + true, + ); +}); + +test("formatProposalStageTransitionMessage formats milestone and stage transitions", () => { + assert.equal( + formatProposalStageTransitionMessage({ + canonicalStage: "vote", + redirectReason: "milestone_vote_open", + pendingMilestoneIndex: 2, + }), + "Milestone M2 entered chamber vote.", + ); + + assert.equal( + formatProposalStageTransitionMessage({ + canonicalStage: "build", + }), + "Proposal moved to Formation.", + ); +}); diff --git a/tests/unit/proposal-submit-errors.test.ts b/tests/unit/proposal-submit-errors.test.ts index 8f9e989..a48ba98 100644 --- a/tests/unit/proposal-submit-errors.test.ts +++ b/tests/unit/proposal-submit-errors.test.ts @@ -31,7 +31,7 @@ test("formats chamber submit eligibility errors", () => { }, }; expect(formatProposalSubmitError(generalError)).toBe( - "General chamber proposals require voting rights in any chamber.", + "Submission to general was blocked by outdated chamber-membership gating. Any eligible human node can submit to any chamber; refresh and retry.", ); const chamberError = { @@ -40,7 +40,7 @@ test("formats chamber submit eligibility errors", () => { }, }; expect(formatProposalSubmitError(chamberError)).toBe( - "Only chamber members can submit to engineering.", + "Submission to engineering was blocked by outdated chamber-membership gating. Any eligible human node can submit to any chamber; refresh and retry.", ); });