From fca8100c8db33604562f3ed501d7d8b3880ea296 Mon Sep 17 00:00:00 2001 From: Cyber-preacher Date: Fri, 20 Feb 2026 19:12:28 +0400 Subject: [PATCH 1/4] Fix Chambers Total ACM double-count - Avoid summing chamber ACM across overlapping governor memberships - Update dto parser unit test to ensure no double-count --- src/lib/dtoParsers.ts | 10 ++++++++-- tests/unit/dto-parsers.test.ts | 6 ++++-- 2 files changed, 12 insertions(+), 4 deletions(-) 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/tests/unit/dto-parsers.test.ts b/tests/unit/dto-parsers.test.ts index 80b55a5..e9417b2 100644 --- a/tests/unit/dto-parsers.test.ts +++ b/tests/unit/dto-parsers.test.ts @@ -23,7 +23,7 @@ test("parsePercent and parseRatio normalize values", () => { expect(parseRatio("bad")).toEqual({ a: 0, b: 0 }); }); -test("chamber numeric stats and metrics aggregate", () => { +test("chamber numeric stats and metrics aggregate without double-counting ACM", () => { const chambers: ChamberDto[] = [ { id: "general", @@ -60,7 +60,9 @@ test("chamber numeric stats and metrics aggregate", () => { const metrics = computeChamberMetrics(chambers); expect(metrics.totalChambers).toBe(2); - expect(metrics.totalAcm).toBe(2000); + // ACM is absolute for a governor set (not chamber-local), so summing across + // chambers would double-count governors who belong to multiple chambers. + expect(metrics.totalAcm).toBe(1200); expect(metrics.liveProposals).toBe(2); }); From 6eed9e16a60ce4890a8a025f7e5923597374a6a6 Mon Sep 17 00:00:00 2001 From: Cyber-preacher Date: Fri, 20 Feb 2026 19:20:57 +0400 Subject: [PATCH 2/4] Align chamber quorum UI with server threshold - Remove misleading '+ 1' chamber quorum label from proposal chamber page - UI now reflects pure percentage quorum participation --- src/pages/proposals/ProposalChamber.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/proposals/ProposalChamber.tsx b/src/pages/proposals/ProposalChamber.tsx index d37628e..6d675cb 100644 --- a/src/pages/proposals/ProposalChamber.tsx +++ b/src/pages/proposals/ProposalChamber.tsx @@ -219,7 +219,6 @@ const ProposalChamber: React.FC = () => { <> {quorumPercent}% / {quorumNeededPercent}% - + 1 {engaged} / {quorumNeeded} From 6b3e782e5c3daa43fffa734ffdda54483cf47400 Mon Sep 17 00:00:00 2001 From: Cyber-preacher Date: Sat, 21 Feb 2026 18:33:54 +0400 Subject: [PATCH 3/4] fix(web): phase-76 stabilization for proposal stages, chamber UX consistency, and live-state reliability - align proposal/chamber surfaces with corrected server stage semantics - add/normalize passed-stage handling for non-formation proposals - remove formation-stage UI expectations where proposal type does not use formation - sync chamber vote tiles and labels with server-calculated quorum/passing outcomes - improve proposal/chamber detail consistency under auto-advancing vote transitions - preserve chamber submission UX for cross-chamber submission policy (submission open, voting scoped) - stabilize formation/chamber page behavior for milestone-driven vote returns and finalization paths - align feed/chamber/proposal rendering with updated DTO behavior from server - ensure address/status presentation remains consistent with governor/tier fixes now emitted by API - synchronize local and VM web runtime files to eliminate drift on production-visible surfaces --- src/app/AppRoutes.tsx | 2 + src/components/ProposalPageHeader.tsx | 4 +- src/components/ProposalStageBar.tsx | 15 +- src/components/StageChip.tsx | 1 + src/lib/apiClient.ts | 8 +- src/lib/proposalSubmitErrors.ts | 11 +- src/pages/chambers/Chambers.tsx | 58 ++++++-- src/pages/cm/CMPanel.tsx | 3 +- src/pages/feed/Feed.tsx | 34 ++++- src/pages/formation/Formation.tsx | 10 +- src/pages/proposals/ProposalChamber.tsx | 25 +++- src/pages/proposals/ProposalFinished.tsx | 165 ++++++++++++++++++++++ src/pages/proposals/ProposalFormation.tsx | 59 +++----- src/pages/proposals/ProposalPP.tsx | 1 + src/pages/proposals/Proposals.tsx | 15 +- src/types/api.ts | 6 +- src/types/stages.ts | 5 +- 17 files changed, 340 insertions(+), 82 deletions(-) create mode 100644 src/pages/proposals/ProposalFinished.tsx 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..21a3df6 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,7 @@ 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..c61c4f2 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,12 @@ 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/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..7ffec19 100644 --- a/src/pages/chambers/Chambers.tsx +++ b/src/pages/chambers/Chambers.tsx @@ -12,9 +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 { apiChambers, apiClock, apiHumans } from "@/lib/apiClient"; import { - computeChamberMetrics, getChamberNumericStats, } from "@/lib/dtoParsers"; import type { ChamberDto } from "@/types/api"; @@ -34,7 +33,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,8 +50,9 @@ const Chambers: React.FC = () => { useEffect(() => { let active = true; (async () => { - const [chambersResult, clockResult] = await Promise.allSettled([ + const [chambersResult, humansResult, clockResult] = await Promise.allSettled([ apiChambers(), + apiHumans(), apiClock(), ]); if (!active) return; @@ -60,10 +65,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 +122,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..7185c81 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..191ba2c 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,15 @@ 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 +113,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 +185,20 @@ 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 +277,7 @@ 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 +354,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 6d675cb..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 { + 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 ?? "—"} - - -