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 = () => { - Open project + {project.stage === "completed" + ? "Open finished" + : "Open project"} 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 ?? "—"} - - - - void runAction(async () => { - if (!id || !pendingMilestone) return; - await apiFormationMilestoneVote({ - proposalId: id, - milestoneIndex: pendingMilestone, - choice: "no", - }); - }) - } + disabled={!canOpenMilestoneVote} + onClick={() => { + if (!id) return; + navigate(`/app/proposals/${id}/chamber`); + }} > - Vote No M{pendingMilestone ?? "—"} + Vote in chamber M{pendingMilestone ?? "—"} { ) : null} + {canOpenMilestoneVote ? ( + + Milestone continuation/release voting is done in the Chamber vote + stage. + + ) : null} + {actionError ? ( {actionError} diff --git a/src/pages/proposals/ProposalPP.tsx b/src/pages/proposals/ProposalPP.tsx index 9ce4f25..876f8a9 100644 --- a/src/pages/proposals/ProposalPP.tsx +++ b/src/pages/proposals/ProposalPP.tsx @@ -132,6 +132,7 @@ const ProposalPP: React.FC = () => { diff --git a/src/pages/proposals/Proposals.tsx b/src/pages/proposals/Proposals.tsx index efb681a..0dee87a 100644 --- a/src/pages/proposals/Proposals.tsx +++ b/src/pages/proposals/Proposals.tsx @@ -209,6 +209,7 @@ const Proposals: React.FC = () => { { value: "pool", label: "Proposal pool" }, { value: "vote", label: "Chamber vote" }, { value: "build", label: "Formation" }, + { value: "passed", label: "Passed" }, { value: "failed", label: "Ended (failed)" }, ], }, @@ -438,12 +439,14 @@ const Proposals: React.FC = () => { @@ -782,11 +785,13 @@ const Proposals: React.FC = () => { ? `/app/proposals/${proposal.id}/pp` : proposal.stage === "vote" ? `/app/proposals/${proposal.id}/chamber` - : proposal.stage === "build" - ? proposal.summaryPill === "Passed" - ? `/app/proposals/${proposal.id}/chamber` - : `/app/proposals/${proposal.id}/formation` - : `/app/proposals/${proposal.id}/pp` + : proposal.stage === "passed" + ? `/app/proposals/${proposal.id}/chamber` + : proposal.stage === "build" + ? proposal.summaryPill === "Finished" + ? `/app/proposals/${proposal.id}/finished` + : `/app/proposals/${proposal.id}/formation` + : `/app/proposals/${proposal.id}/pp` } primaryLabel={proposal.ctaPrimary} /> diff --git a/src/types/api.ts b/src/types/api.ts index 378e67f..f26ce97 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -3,7 +3,7 @@ import type { FeedStage } from "./stages"; -export type ProposalStageDto = "pool" | "vote" | "build" | "failed"; +export type ProposalStageDto = "pool" | "vote" | "build" | "passed" | "failed"; export type FeedStageDto = FeedStage; export type ToneDto = "ok" | "warn"; @@ -531,6 +531,8 @@ export type ChamberProposalPageDto = { proposer: string; proposerId: string; chamber: string; + scoreLabel: "CM" | "MM"; + milestoneIndex: number | null; budget: string; formationEligible: boolean; teamSlots: string; @@ -577,7 +579,7 @@ export type FormationProposalPageDto = { projectState?: | "active" | "awaiting_milestone_vote" - | "suspended" + | "canceled" | "ready_to_finish" | "completed"; pendingMilestoneIndex?: number | null; diff --git a/src/types/stages.ts b/src/types/stages.ts index 4af7270..a6bb435 100644 --- a/src/types/stages.ts +++ b/src/types/stages.ts @@ -1,4 +1,10 @@ -export const proposalStages = ["pool", "vote", "build", "failed"] as const; +export const proposalStages = [ + "pool", + "vote", + "build", + "passed", + "failed", +] as const; export type ProposalStage = (typeof proposalStages)[number]; @@ -19,6 +25,7 @@ export type StageChipKind = | "proposal_pool" | "chamber_vote" | "formation" + | "passed" | "thread" | "courts" | "faction"; @@ -27,6 +34,7 @@ export const stageToChipKind = { pool: "proposal_pool", vote: "chamber_vote", build: "formation", + passed: "passed", failed: "chamber_vote", thread: "thread", courts: "courts", @@ -37,6 +45,7 @@ export const stageLabel = { pool: "Proposal pool", vote: "Chamber vote", build: "Formation", + passed: "Passed", failed: "Ended", thread: "Thread", courts: "Courts", 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); });
{entry.title}
{entry.description}
{entry.value}
+ Milestone continuation/release voting is done in the Chamber vote + stage. +
{actionError} diff --git a/src/pages/proposals/ProposalPP.tsx b/src/pages/proposals/ProposalPP.tsx index 9ce4f25..876f8a9 100644 --- a/src/pages/proposals/ProposalPP.tsx +++ b/src/pages/proposals/ProposalPP.tsx @@ -132,6 +132,7 @@ const ProposalPP: React.FC = () => { diff --git a/src/pages/proposals/Proposals.tsx b/src/pages/proposals/Proposals.tsx index efb681a..0dee87a 100644 --- a/src/pages/proposals/Proposals.tsx +++ b/src/pages/proposals/Proposals.tsx @@ -209,6 +209,7 @@ const Proposals: React.FC = () => { { value: "pool", label: "Proposal pool" }, { value: "vote", label: "Chamber vote" }, { value: "build", label: "Formation" }, + { value: "passed", label: "Passed" }, { value: "failed", label: "Ended (failed)" }, ], }, @@ -438,12 +439,14 @@ const Proposals: React.FC = () => { @@ -782,11 +785,13 @@ const Proposals: React.FC = () => { ? `/app/proposals/${proposal.id}/pp` : proposal.stage === "vote" ? `/app/proposals/${proposal.id}/chamber` - : proposal.stage === "build" - ? proposal.summaryPill === "Passed" - ? `/app/proposals/${proposal.id}/chamber` - : `/app/proposals/${proposal.id}/formation` - : `/app/proposals/${proposal.id}/pp` + : proposal.stage === "passed" + ? `/app/proposals/${proposal.id}/chamber` + : proposal.stage === "build" + ? proposal.summaryPill === "Finished" + ? `/app/proposals/${proposal.id}/finished` + : `/app/proposals/${proposal.id}/formation` + : `/app/proposals/${proposal.id}/pp` } primaryLabel={proposal.ctaPrimary} /> diff --git a/src/types/api.ts b/src/types/api.ts index 378e67f..f26ce97 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -3,7 +3,7 @@ import type { FeedStage } from "./stages"; -export type ProposalStageDto = "pool" | "vote" | "build" | "failed"; +export type ProposalStageDto = "pool" | "vote" | "build" | "passed" | "failed"; export type FeedStageDto = FeedStage; export type ToneDto = "ok" | "warn"; @@ -531,6 +531,8 @@ export type ChamberProposalPageDto = { proposer: string; proposerId: string; chamber: string; + scoreLabel: "CM" | "MM"; + milestoneIndex: number | null; budget: string; formationEligible: boolean; teamSlots: string; @@ -577,7 +579,7 @@ export type FormationProposalPageDto = { projectState?: | "active" | "awaiting_milestone_vote" - | "suspended" + | "canceled" | "ready_to_finish" | "completed"; pendingMilestoneIndex?: number | null; diff --git a/src/types/stages.ts b/src/types/stages.ts index 4af7270..a6bb435 100644 --- a/src/types/stages.ts +++ b/src/types/stages.ts @@ -1,4 +1,10 @@ -export const proposalStages = ["pool", "vote", "build", "failed"] as const; +export const proposalStages = [ + "pool", + "vote", + "build", + "passed", + "failed", +] as const; export type ProposalStage = (typeof proposalStages)[number]; @@ -19,6 +25,7 @@ export type StageChipKind = | "proposal_pool" | "chamber_vote" | "formation" + | "passed" | "thread" | "courts" | "faction"; @@ -27,6 +34,7 @@ export const stageToChipKind = { pool: "proposal_pool", vote: "chamber_vote", build: "formation", + passed: "passed", failed: "chamber_vote", thread: "thread", courts: "courts", @@ -37,6 +45,7 @@ export const stageLabel = { pool: "Proposal pool", vote: "Chamber vote", build: "Formation", + passed: "Passed", failed: "Ended", thread: "Thread", courts: "Courts", 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); });