diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index e17f8d8..924f319 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -16,6 +16,7 @@ import FactionCreate from "../pages/factions/FactionCreate"; import ProposalPP from "../pages/proposals/ProposalPP"; import ProposalChamber from "../pages/proposals/ProposalChamber"; import ProposalFormation from "../pages/proposals/ProposalFormation"; +import ProposalFinished from "../pages/proposals/ProposalFinished"; import Profile from "../pages/profile/Profile"; import HumanNode from "../pages/human-nodes/HumanNode"; import Chamber from "../pages/chambers/Chamber"; @@ -90,6 +91,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/ProposalPageHeader.tsx b/src/components/ProposalPageHeader.tsx index 3645c37..9bd460a 100644 --- a/src/components/ProposalPageHeader.tsx +++ b/src/components/ProposalPageHeader.tsx @@ -10,6 +10,7 @@ import { StatTile } from "@/components/StatTile"; type ProposalPageHeaderProps = { title: string; stage: ProposalStage; + showFormationStage?: boolean; chamber: string; proposer: string; children?: ReactNode; @@ -18,6 +19,7 @@ type ProposalPageHeaderProps = { export function ProposalPageHeader({ title, stage, + showFormationStage = true, chamber, proposer, children, @@ -25,7 +27,10 @@ export function ProposalPageHeader({ return (

{title}

- +
= ({ current, + showFormationStage = true, className, }) => { - const stages: { + const allStages: { key: ProposalStage; label: string; render?: React.ReactNode; @@ -33,7 +35,12 @@ export const ProposalStageBar: React.FC = ({ label: "Formation", render: Formation, }, + { key: "passed", label: "Passed" }, ]; + const stages = allStages.filter( + (stage) => + stage.key !== "build" || showFormationStage || current === "build", + ); return (
@@ -46,7 +53,9 @@ export const ProposalStageBar: React.FC = ({ ? "bg-primary text-[var(--primary-foreground)]" : stage.key === "vote" ? "bg-[var(--accent)] text-[var(--accent-foreground)]" - : "bg-[var(--accent-warm)] text-[var(--text)]"; + : stage.key === "build" + ? "bg-[var(--accent-warm)] text-[var(--text)]" + : "bg-[color:var(--ok)]/20 text-[color:var(--ok)]"; return (
= { proposal_pool: "bg-[color:var(--accent-warm)]/15 text-[var(--accent-warm)]", chamber_vote: "bg-[color:var(--accent)]/15 text-[var(--accent)]", formation: "bg-[color:var(--primary)]/12 text-primary", + passed: "bg-[color:var(--ok)]/20 text-[color:var(--ok)]", thread: "bg-panel-alt text-muted", courts: "bg-[color:var(--accent-warm)]/15 text-[var(--accent-warm)]", faction: "bg-panel-alt text-muted", diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index 8094920..dcf3613 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -550,7 +550,7 @@ export async function apiFormationMilestoneVote(input: { projectState: | "active" | "awaiting_milestone_vote" - | "suspended" + | "canceled" | "ready_to_finish" | "completed"; pendingMilestoneIndex: number | null; @@ -614,6 +614,14 @@ export async function apiProposalFormationPage( ); } +export async function apiProposalFinishedPage( + id: string, +): Promise { + return await apiGet( + `/api/proposals/${id}/finished`, + ); +} + export async function apiCourts(): Promise { return await apiGet("/api/courts"); } diff --git a/src/lib/dtoParsers.ts b/src/lib/dtoParsers.ts index 29254de..1dd0313 100644 --- a/src/lib/dtoParsers.ts +++ b/src/lib/dtoParsers.ts @@ -34,9 +34,15 @@ export function getChamberNumericStats(chamber: ChamberDto) { } export function computeChamberMetrics(chambers: ChamberDto[]) { - const totalAcm = chambers.reduce((sum, chamber) => { + // Governors can be members of multiple chambers, and `stats.acm` for a chamber + // is an absolute total for that chamber's governor set (not a chamber-local slice). + // Summing across chambers would double-count governors who are members of more than one chamber. + // + // The General chamber includes the full governor set, so the largest ACM total is a stable + // approximation for "Total ACM" across unique governors. + const totalAcm = chambers.reduce((max, chamber) => { const { acm } = getChamberNumericStats(chamber); - return sum + acm; + return Math.max(max, acm); }, 0); // Governors can be members of multiple chambers; use the largest chamber roster // as a stable approximation of global governors for the summary tile. diff --git a/src/lib/proposalSubmitErrors.ts b/src/lib/proposalSubmitErrors.ts index 54089e9..349bf06 100644 --- a/src/lib/proposalSubmitErrors.ts +++ b/src/lib/proposalSubmitErrors.ts @@ -21,13 +21,10 @@ export function formatProposalSubmitError(error: unknown): string { if (code === "proposal_submit_ineligible") { const chamberId = - typeof details.chamberId === "string" ? details.chamberId : ""; - if (chamberId === "general") { - return "General chamber proposals require voting rights in any chamber."; - } - if (chamberId) { - return `Only chamber members can submit to ${formatProposalType(chamberId)}.`; - } + typeof details.chamberId === "string" + ? details.chamberId + : "this chamber"; + return `Submission to ${formatProposalType(chamberId)} was blocked by outdated chamber-membership gating. Any eligible human node can submit to any chamber; refresh and retry.`; } if (code === "draft_not_submittable") { diff --git a/src/pages/chambers/Chambers.tsx b/src/pages/chambers/Chambers.tsx index 2713402..d33ede0 100644 --- a/src/pages/chambers/Chambers.tsx +++ b/src/pages/chambers/Chambers.tsx @@ -12,11 +12,8 @@ import { Button } from "@/components/primitives/button"; import { Link } from "react-router"; import { InlineHelp } from "@/components/InlineHelp"; import { NoDataYetBar } from "@/components/NoDataYetBar"; -import { apiChambers, apiClock } from "@/lib/apiClient"; -import { - computeChamberMetrics, - getChamberNumericStats, -} from "@/lib/dtoParsers"; +import { apiChambers, apiClock, apiHumans } from "@/lib/apiClient"; +import { getChamberNumericStats } from "@/lib/dtoParsers"; import type { ChamberDto } from "@/types/api"; import { Surface } from "@/components/Surface"; @@ -34,7 +31,12 @@ const metricCards: Metric[] = [ const Chambers: React.FC = () => { const [chambers, setChambers] = useState(null); - const [activeGovernors, setActiveGovernors] = useState(null); + const [globalMetrics, setGlobalMetrics] = useState<{ + governors: number; + activeGovernors: number; + totalAcm: number; + } | null>(null); + const [currentEra, setCurrentEra] = useState(null); const [loadError, setLoadError] = useState(null); const [search, setSearch] = useState(""); const [filters, setFilters] = useState<{ @@ -46,10 +48,8 @@ const Chambers: React.FC = () => { useEffect(() => { let active = true; (async () => { - const [chambersResult, clockResult] = await Promise.allSettled([ - apiChambers(), - apiClock(), - ]); + const [chambersResult, humansResult, clockResult] = + await Promise.allSettled([apiChambers(), apiHumans(), apiClock()]); if (!active) return; if (chambersResult.status === "fulfilled") { @@ -60,10 +60,27 @@ const Chambers: React.FC = () => { setLoadError((chambersResult.reason as Error).message); } + if (humansResult.status === "fulfilled") { + const governorItems = humansResult.value.items.filter( + (item) => item.tier !== "nominee", + ); + const governors = governorItems.length; + const activeGovernors = governorItems.filter( + (item) => item.active.governorActive, + ).length; + const totalAcm = governorItems.reduce( + (sum, item) => sum + (item.cmTotals?.acm ?? item.acm ?? 0), + 0, + ); + setGlobalMetrics({ governors, activeGovernors, totalAcm }); + } else { + setGlobalMetrics(null); + } + if (clockResult.status === "fulfilled") { - setActiveGovernors(clockResult.value.activeGovernors); + setCurrentEra(clockResult.value.currentEra); } else { - setActiveGovernors(null); + setCurrentEra(null); } })(); return () => { @@ -100,19 +117,33 @@ const Chambers: React.FC = () => { const computedMetrics = useMemo((): Metric[] => { if (!chambers) return metricCards; - const { governors, totalAcm, liveProposals } = - computeChamberMetrics(chambers); - const active = typeof activeGovernors === "number" ? activeGovernors : "—"; + const liveProposals = chambers.reduce( + (sum, chamber) => sum + (chamber.pipeline.vote ?? 0), + 0, + ); + const governorsCount = globalMetrics?.governors; + const activeCount = + typeof governorsCount === "number" + ? currentEra === 0 + ? governorsCount + : (globalMetrics?.activeGovernors ?? governorsCount) + : null; + const governors = typeof governorsCount === "number" ? governorsCount : "—"; + const active = typeof activeCount === "number" ? activeCount : "—"; + const totalAcm = globalMetrics?.totalAcm; return [ { label: "Total chambers", value: String(chambers.length) }, { label: "Governors / Active governors", value: `${governors} / ${active}`, }, - { label: "Total ACM", value: totalAcm.toLocaleString() }, + { + label: "Total ACM", + value: typeof totalAcm === "number" ? totalAcm.toLocaleString() : "—", + }, { label: "Live proposals", value: String(liveProposals) }, ]; - }, [chambers, activeGovernors]); + }, [chambers, currentEra, globalMetrics]); return (
diff --git a/src/pages/cm/CMPanel.tsx b/src/pages/cm/CMPanel.tsx index 03ada21..2b3885e 100644 --- a/src/pages/cm/CMPanel.tsx +++ b/src/pages/cm/CMPanel.tsx @@ -176,7 +176,8 @@ const CMPanel: React.FC = () => { Set your CM{" "} multipliers for chambers you are not a member of. Chambers you belong - to are blurred and not adjustable here. + to are blurred and not adjustable here. If you submit a new number for + the same chamber later, it replaces your previous submission. diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx index ddf4e8f..2bad320 100644 --- a/src/pages/feed/Feed.tsx +++ b/src/pages/feed/Feed.tsx @@ -15,11 +15,13 @@ import { NoDataYetBar } from "@/components/NoDataYetBar"; import { ToggleGroup } from "@/components/ToggleGroup"; import { formatDateTime } from "@/lib/dateTime"; import { + apiClock, apiCourt, apiFeed, apiFactionCofounderInviteAccept, apiFactionCofounderInviteDecline, apiHuman, + apiMyGovernance, apiProposalChamberPage, apiProposalFormationPage, apiProposalPoolPage, @@ -93,8 +95,16 @@ const urgentEntityKey = (item: FeedItemDto) => { const isUrgentItemInteractable = ( item: FeedItemDto, isGovernorActive: boolean, + viewerAddress?: string, ) => { if (item.actionable !== true) return false; + if (item.stage === "build") { + const viewer = viewerAddress?.trim().toLowerCase(); + const proposer = (item.proposerId ?? item.proposer ?? "") + .trim() + .toLowerCase(); + return Boolean(viewer && proposer && viewer === proposer); + } if ((item.stage === "pool" || item.stage === "vote") && !isGovernorActive) { return false; } @@ -104,9 +114,10 @@ const isUrgentItemInteractable = ( const toUrgentItems = ( items: FeedItemDto[], isGovernorActive: boolean, + viewerAddress?: string, ): FeedItemDto[] => { const filtered = items.filter((item) => - isUrgentItemInteractable(item, isGovernorActive), + isUrgentItemInteractable(item, isGovernorActive, viewerAddress), ); const deduped = new Map(); for (const item of filtered) { @@ -175,15 +186,23 @@ const Feed: React.FC = () => { setChambersLoading(true); (async () => { try { - const profile = await apiHuman(address); + const [governance, profile, clock] = await Promise.all([ + apiMyGovernance(), + apiHuman(address), + apiClock(), + ]); if (!active) return; - const chamberIds = - profile.cmChambers?.map((chamber) => chamber.chamberId) ?? []; + const tier = profile.tierProgress?.tier?.trim().toLowerCase() ?? ""; + const bootstrapGovernor = + clock.currentEra === 0 && tier !== "" && tier !== "nominee"; + const chamberIds = governance.myChamberIds ?? []; const unique = Array.from( new Set(["general", ...chamberIds.map((id) => id.toLowerCase())]), ); setChamberFilters(unique); - setViewerGovernorActive(Boolean(profile.governorActive)); + setViewerGovernorActive( + Boolean(profile.governorActive) || bootstrapGovernor, + ); } catch (error) { if (!active) return; setChamberFilters([]); @@ -262,7 +281,11 @@ const Feed: React.FC = () => { } const filteredItems = feedScope === "urgent" - ? toUrgentItems(items, viewerGovernorActive) + ? toUrgentItems( + items, + viewerGovernorActive, + auth.address ?? undefined, + ) : items; setFeedItems(filteredItems); setNextCursor(res.nextCursor ?? null); @@ -339,13 +362,18 @@ const Feed: React.FC = () => { }); const items = feedScope === "urgent" - ? toUrgentItems(res.items, viewerGovernorActive) + ? toUrgentItems( + res.items, + viewerGovernorActive, + auth.address ?? undefined, + ) : res.items; setFeedItems((curr) => { if (feedScope === "urgent") { return toUrgentItems( [...(curr ?? []), ...items], viewerGovernorActive, + auth.address ?? undefined, ); } const existing = new Set((curr ?? []).map(feedItemKey)); diff --git a/src/pages/formation/Formation.tsx b/src/pages/formation/Formation.tsx index 279efef..0a96805 100644 --- a/src/pages/formation/Formation.tsx +++ b/src/pages/formation/Formation.tsx @@ -198,9 +198,15 @@ const Formation: React.FC = () => {
diff --git a/src/pages/proposals/ProposalChamber.tsx b/src/pages/proposals/ProposalChamber.tsx index d37628e..1a72654 100644 --- a/src/pages/proposals/ProposalChamber.tsx +++ b/src/pages/proposals/ProposalChamber.tsx @@ -117,6 +117,16 @@ const ProposalChamber: React.FC = () => { const abstainPercentOfTotal = totalVotes > 0 ? Math.round((abstainTotal / totalVotes) * 100) : 0; const passingNeededPercent = 66.6; + const milestoneVoteIndex = + typeof proposal.milestoneIndex === "number" && proposal.milestoneIndex > 0 + ? proposal.milestoneIndex + : null; + const scoreLabel = + proposal.scoreLabel === "MM" || milestoneVoteIndex !== null ? "MM" : "CM"; + const chamberTitle = + milestoneVoteIndex !== null + ? `${proposal.title} — Milestone vote (M${milestoneVoteIndex})` + : proposal.title; const [filledSlots, totalSlots] = proposal.teamSlots .split("/") @@ -148,11 +158,22 @@ const ProposalChamber: React.FC = () => {
+ {milestoneVoteIndex !== null ? ( + + Milestone vote: M{milestoneVoteIndex} + + ) : null}
{ />
- CM score + {scoreLabel} score { <> {quorumPercent}% / {quorumNeededPercent}% - + 1 {engaged} / {quorumNeeded} diff --git a/src/pages/proposals/ProposalFinished.tsx b/src/pages/proposals/ProposalFinished.tsx new file mode 100644 index 0000000..c310af1 --- /dev/null +++ b/src/pages/proposals/ProposalFinished.tsx @@ -0,0 +1,165 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router"; +import { Surface } from "@/components/Surface"; +import { PageHint } from "@/components/PageHint"; +import { ProposalPageHeader } from "@/components/ProposalPageHeader"; +import { + ProposalInvisionInsightCard, + ProposalSummaryCard, + ProposalTeamMilestonesCard, + ProposalTimelineCard, +} from "@/components/ProposalSections"; +import { apiProposalFinishedPage, apiProposalTimeline } from "@/lib/apiClient"; +import type { + FormationProposalPageDto, + ProposalTimelineItemDto, +} from "@/types/api"; + +const ProposalFinished: React.FC = () => { + const { id } = useParams(); + const [project, setProject] = useState(null); + const [loadError, setLoadError] = useState(null); + const [timeline, setTimeline] = useState([]); + const [timelineError, setTimelineError] = useState(null); + + useEffect(() => { + if (!id) return; + let active = true; + (async () => { + try { + const [pageResult, timelineResult] = await Promise.allSettled([ + apiProposalFinishedPage(id), + apiProposalTimeline(id), + ]); + if (!active) return; + if (pageResult.status === "fulfilled") { + setProject(pageResult.value); + setLoadError(null); + } else { + setProject(null); + setLoadError(pageResult.reason?.message ?? "Failed to load proposal"); + } + if (timelineResult.status === "fulfilled") { + setTimeline(timelineResult.value.items); + setTimelineError(null); + } else { + setTimeline([]); + setTimelineError( + timelineResult.reason?.message ?? "Failed to load timeline", + ); + } + } catch (error) { + if (!active) return; + setProject(null); + setLoadError((error as Error).message); + } + })(); + return () => { + active = false; + }; + }, [id]); + + if (!project) { + return ( +
+ + + {loadError + ? `Proposal unavailable: ${loadError}` + : "Loading proposal…"} + +
+ ); + } + + const isCanceled = project.projectState === "canceled"; + + return ( +
+ + + +
+

+ {isCanceled ? "Canceled project" : "Finished project"} +

+ + {isCanceled + ? "This project ended as canceled." + : "This project has completed formation execution."} + +
+ +
+

Project status

+
+ {project.stageData.map((entry) => ( + +

{entry.title}

+

{entry.description}

+

{entry.value}

+
+ ))} +
+
+ + + + + + + + {timelineError ? ( + + Timeline unavailable: {timelineError} + + ) : ( + + )} +
+ ); +}; + +export default ProposalFinished; diff --git a/src/pages/proposals/ProposalFormation.tsx b/src/pages/proposals/ProposalFormation.tsx index b6d9c4b..65eeef2 100644 --- a/src/pages/proposals/ProposalFormation.tsx +++ b/src/pages/proposals/ProposalFormation.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useParams } from "react-router"; +import { useNavigate, useParams } from "react-router"; import { Surface } from "@/components/Surface"; import { PageHint } from "@/components/PageHint"; import { ProposalPageHeader } from "@/components/ProposalPageHeader"; @@ -13,7 +13,6 @@ import { import { apiFormationJoin, apiFormationMilestoneSubmit, - apiFormationMilestoneVote, apiFormationProjectFinish, apiProposalFormationPage, apiProposalTimeline, @@ -26,6 +25,7 @@ import type { const ProposalFormation: React.FC = () => { const { id } = useParams(); + const navigate = useNavigate(); const [project, setProject] = useState(null); const [loadError, setLoadError] = useState(null); const [actionError, setActionError] = useState(null); @@ -112,7 +112,7 @@ const ProposalFormation: React.FC = () => { const canJoinProject = project.projectState !== "ready_to_finish" && project.projectState !== "completed" && - project.projectState !== "suspended"; + project.projectState !== "canceled"; const canSubmitMilestone = auth.authenticated && auth.eligible && @@ -121,10 +121,7 @@ const ProposalFormation: React.FC = () => { typeof nextMilestone === "number" && nextMilestone > 0 && nextMilestone <= milestones.total; - const canVoteMilestone = - auth.authenticated && - auth.eligible && - !actionBusy && + const canOpenMilestoneVote = project.projectState === "awaiting_milestone_vote" && typeof pendingMilestone === "number" && pendingMilestone > 0; @@ -133,6 +130,8 @@ const ProposalFormation: React.FC = () => { isProposerViewer && !actionBusy && project.projectState === "ready_to_finish"; + const stageForHeader = + project.projectState === "awaiting_milestone_vote" ? "vote" : "build"; const runAction = async (fn: () => Promise) => { setActionError(null); @@ -155,7 +154,7 @@ const ProposalFormation: React.FC = () => { @@ -211,38 +210,13 @@ const ProposalFormation: React.FC = () => { type="button" size="lg" variant="outline" - disabled={!canVoteMilestone} - onClick={() => - void runAction(async () => { - if (!id || !pendingMilestone) return; - await apiFormationMilestoneVote({ - proposalId: id, - milestoneIndex: pendingMilestone, - choice: "yes", - }); - }) - } - > - Vote Yes M{pendingMilestone ?? "—"} - - -