Skip to content

Commit 70f798e

Browse files
Phase 76 more bug fixes (#33)
* Fix Chambers Total ACM double-count - Avoid summing chamber ACM across overlapping governor memberships - Update dto parser unit test to ensure no double-count * Align chamber quorum UI with server threshold - Remove misleading '+ 1' chamber quorum label from proposal chamber page - UI now reflects pure percentage quorum participation * 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 * prettier fixes
1 parent 718fe19 commit 70f798e

19 files changed

Lines changed: 378 additions & 99 deletions

src/app/AppRoutes.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import FactionCreate from "../pages/factions/FactionCreate";
1616
import ProposalPP from "../pages/proposals/ProposalPP";
1717
import ProposalChamber from "../pages/proposals/ProposalChamber";
1818
import ProposalFormation from "../pages/proposals/ProposalFormation";
19+
import ProposalFinished from "../pages/proposals/ProposalFinished";
1920
import Profile from "../pages/profile/Profile";
2021
import HumanNode from "../pages/human-nodes/HumanNode";
2122
import Chamber from "../pages/chambers/Chamber";
@@ -90,6 +91,7 @@ const AppRoutes: React.FC = () => {
9091
<Route path="proposals/:id/pp" element={<ProposalPP />} />
9192
<Route path="proposals/:id/chamber" element={<ProposalChamber />} />
9293
<Route path="proposals/:id/formation" element={<ProposalFormation />} />
94+
<Route path="proposals/:id/finished" element={<ProposalFinished />} />
9395
<Route path="chambers" element={<Chambers />} />
9496
<Route path="chambers/:id" element={<Chamber />} />
9597
<Route path="formation" element={<Formation />} />

src/components/ProposalPageHeader.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { StatTile } from "@/components/StatTile";
1010
type ProposalPageHeaderProps = {
1111
title: string;
1212
stage: ProposalStage;
13+
showFormationStage?: boolean;
1314
chamber: string;
1415
proposer: string;
1516
children?: ReactNode;
@@ -18,14 +19,18 @@ type ProposalPageHeaderProps = {
1819
export function ProposalPageHeader({
1920
title,
2021
stage,
22+
showFormationStage = true,
2123
chamber,
2224
proposer,
2325
children,
2426
}: ProposalPageHeaderProps) {
2527
return (
2628
<section className="space-y-4">
2729
<h1 className="text-center text-2xl font-semibold text-text">{title}</h1>
28-
<ProposalStageBar current={stage} />
30+
<ProposalStageBar
31+
current={stage}
32+
showFormationStage={showFormationStage}
33+
/>
2934
<div className="grid gap-3 sm:grid-cols-2">
3035
<StatTile
3136
label="Chamber"

src/components/ProposalStageBar.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import React from "react";
22
import { HintLabel } from "@/components/Hint";
33

4-
export type ProposalStage = "draft" | "pool" | "vote" | "build";
4+
export type ProposalStage = "draft" | "pool" | "vote" | "build" | "passed";
55

66
type ProposalStageBarProps = {
77
current: ProposalStage;
8+
showFormationStage?: boolean;
89
className?: string;
910
};
1011

1112
export const ProposalStageBar: React.FC<ProposalStageBarProps> = ({
1213
current,
14+
showFormationStage = true,
1315
className,
1416
}) => {
15-
const stages: {
17+
const allStages: {
1618
key: ProposalStage;
1719
label: string;
1820
render?: React.ReactNode;
@@ -33,7 +35,12 @@ export const ProposalStageBar: React.FC<ProposalStageBarProps> = ({
3335
label: "Formation",
3436
render: <HintLabel termId="formation">Formation</HintLabel>,
3537
},
38+
{ key: "passed", label: "Passed" },
3639
];
40+
const stages = allStages.filter(
41+
(stage) =>
42+
stage.key !== "build" || showFormationStage || current === "build",
43+
);
3744

3845
return (
3946
<div className={["flex gap-2", className].filter(Boolean).join(" ")}>
@@ -46,7 +53,9 @@ export const ProposalStageBar: React.FC<ProposalStageBarProps> = ({
4653
? "bg-primary text-[var(--primary-foreground)]"
4754
: stage.key === "vote"
4855
? "bg-[var(--accent)] text-[var(--accent-foreground)]"
49-
: "bg-[var(--accent-warm)] text-[var(--text)]";
56+
: stage.key === "build"
57+
? "bg-[var(--accent-warm)] text-[var(--text)]"
58+
: "bg-[color:var(--ok)]/20 text-[color:var(--ok)]";
5059
return (
5160
<div
5261
key={stage.key}

src/components/StageChip.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const chipClasses: Record<StageChipKind, string> = {
1313
proposal_pool: "bg-[color:var(--accent-warm)]/15 text-[var(--accent-warm)]",
1414
chamber_vote: "bg-[color:var(--accent)]/15 text-[var(--accent)]",
1515
formation: "bg-[color:var(--primary)]/12 text-primary",
16+
passed: "bg-[color:var(--ok)]/20 text-[color:var(--ok)]",
1617
thread: "bg-panel-alt text-muted",
1718
courts: "bg-[color:var(--accent-warm)]/15 text-[var(--accent-warm)]",
1819
faction: "bg-panel-alt text-muted",

src/lib/apiClient.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,7 @@ export async function apiFormationMilestoneVote(input: {
550550
projectState:
551551
| "active"
552552
| "awaiting_milestone_vote"
553-
| "suspended"
553+
| "canceled"
554554
| "ready_to_finish"
555555
| "completed";
556556
pendingMilestoneIndex: number | null;
@@ -614,6 +614,14 @@ export async function apiProposalFormationPage(
614614
);
615615
}
616616

617+
export async function apiProposalFinishedPage(
618+
id: string,
619+
): Promise<FormationProposalPageDto> {
620+
return await apiGet<FormationProposalPageDto>(
621+
`/api/proposals/${id}/finished`,
622+
);
623+
}
624+
617625
export async function apiCourts(): Promise<GetCourtsResponse> {
618626
return await apiGet<GetCourtsResponse>("/api/courts");
619627
}

src/lib/dtoParsers.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,15 @@ export function getChamberNumericStats(chamber: ChamberDto) {
3434
}
3535

3636
export function computeChamberMetrics(chambers: ChamberDto[]) {
37-
const totalAcm = chambers.reduce((sum, chamber) => {
37+
// Governors can be members of multiple chambers, and `stats.acm` for a chamber
38+
// is an absolute total for that chamber's governor set (not a chamber-local slice).
39+
// Summing across chambers would double-count governors who are members of more than one chamber.
40+
//
41+
// The General chamber includes the full governor set, so the largest ACM total is a stable
42+
// approximation for "Total ACM" across unique governors.
43+
const totalAcm = chambers.reduce((max, chamber) => {
3844
const { acm } = getChamberNumericStats(chamber);
39-
return sum + acm;
45+
return Math.max(max, acm);
4046
}, 0);
4147
// Governors can be members of multiple chambers; use the largest chamber roster
4248
// as a stable approximation of global governors for the summary tile.

src/lib/proposalSubmitErrors.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,10 @@ export function formatProposalSubmitError(error: unknown): string {
2121

2222
if (code === "proposal_submit_ineligible") {
2323
const chamberId =
24-
typeof details.chamberId === "string" ? details.chamberId : "";
25-
if (chamberId === "general") {
26-
return "General chamber proposals require voting rights in any chamber.";
27-
}
28-
if (chamberId) {
29-
return `Only chamber members can submit to ${formatProposalType(chamberId)}.`;
30-
}
24+
typeof details.chamberId === "string"
25+
? details.chamberId
26+
: "this chamber";
27+
return `Submission to ${formatProposalType(chamberId)} was blocked by outdated chamber-membership gating. Any eligible human node can submit to any chamber; refresh and retry.`;
3128
}
3229

3330
if (code === "draft_not_submittable") {

src/pages/chambers/Chambers.tsx

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,8 @@ import { Button } from "@/components/primitives/button";
1212
import { Link } from "react-router";
1313
import { InlineHelp } from "@/components/InlineHelp";
1414
import { NoDataYetBar } from "@/components/NoDataYetBar";
15-
import { apiChambers, apiClock } from "@/lib/apiClient";
16-
import {
17-
computeChamberMetrics,
18-
getChamberNumericStats,
19-
} from "@/lib/dtoParsers";
15+
import { apiChambers, apiClock, apiHumans } from "@/lib/apiClient";
16+
import { getChamberNumericStats } from "@/lib/dtoParsers";
2017
import type { ChamberDto } from "@/types/api";
2118
import { Surface } from "@/components/Surface";
2219

@@ -34,7 +31,12 @@ const metricCards: Metric[] = [
3431

3532
const Chambers: React.FC = () => {
3633
const [chambers, setChambers] = useState<ChamberDto[] | null>(null);
37-
const [activeGovernors, setActiveGovernors] = useState<number | null>(null);
34+
const [globalMetrics, setGlobalMetrics] = useState<{
35+
governors: number;
36+
activeGovernors: number;
37+
totalAcm: number;
38+
} | null>(null);
39+
const [currentEra, setCurrentEra] = useState<number | null>(null);
3840
const [loadError, setLoadError] = useState<string | null>(null);
3941
const [search, setSearch] = useState("");
4042
const [filters, setFilters] = useState<{
@@ -46,10 +48,8 @@ const Chambers: React.FC = () => {
4648
useEffect(() => {
4749
let active = true;
4850
(async () => {
49-
const [chambersResult, clockResult] = await Promise.allSettled([
50-
apiChambers(),
51-
apiClock(),
52-
]);
51+
const [chambersResult, humansResult, clockResult] =
52+
await Promise.allSettled([apiChambers(), apiHumans(), apiClock()]);
5353
if (!active) return;
5454

5555
if (chambersResult.status === "fulfilled") {
@@ -60,10 +60,27 @@ const Chambers: React.FC = () => {
6060
setLoadError((chambersResult.reason as Error).message);
6161
}
6262

63+
if (humansResult.status === "fulfilled") {
64+
const governorItems = humansResult.value.items.filter(
65+
(item) => item.tier !== "nominee",
66+
);
67+
const governors = governorItems.length;
68+
const activeGovernors = governorItems.filter(
69+
(item) => item.active.governorActive,
70+
).length;
71+
const totalAcm = governorItems.reduce(
72+
(sum, item) => sum + (item.cmTotals?.acm ?? item.acm ?? 0),
73+
0,
74+
);
75+
setGlobalMetrics({ governors, activeGovernors, totalAcm });
76+
} else {
77+
setGlobalMetrics(null);
78+
}
79+
6380
if (clockResult.status === "fulfilled") {
64-
setActiveGovernors(clockResult.value.activeGovernors);
81+
setCurrentEra(clockResult.value.currentEra);
6582
} else {
66-
setActiveGovernors(null);
83+
setCurrentEra(null);
6784
}
6885
})();
6986
return () => {
@@ -100,19 +117,33 @@ const Chambers: React.FC = () => {
100117

101118
const computedMetrics = useMemo((): Metric[] => {
102119
if (!chambers) return metricCards;
103-
const { governors, totalAcm, liveProposals } =
104-
computeChamberMetrics(chambers);
105-
const active = typeof activeGovernors === "number" ? activeGovernors : "—";
120+
const liveProposals = chambers.reduce(
121+
(sum, chamber) => sum + (chamber.pipeline.vote ?? 0),
122+
0,
123+
);
124+
const governorsCount = globalMetrics?.governors;
125+
const activeCount =
126+
typeof governorsCount === "number"
127+
? currentEra === 0
128+
? governorsCount
129+
: (globalMetrics?.activeGovernors ?? governorsCount)
130+
: null;
131+
const governors = typeof governorsCount === "number" ? governorsCount : "—";
132+
const active = typeof activeCount === "number" ? activeCount : "—";
133+
const totalAcm = globalMetrics?.totalAcm;
106134
return [
107135
{ label: "Total chambers", value: String(chambers.length) },
108136
{
109137
label: "Governors / Active governors",
110138
value: `${governors} / ${active}`,
111139
},
112-
{ label: "Total ACM", value: totalAcm.toLocaleString() },
140+
{
141+
label: "Total ACM",
142+
value: typeof totalAcm === "number" ? totalAcm.toLocaleString() : "—",
143+
},
113144
{ label: "Live proposals", value: String(liveProposals) },
114145
];
115-
}, [chambers, activeGovernors]);
146+
}, [chambers, currentEra, globalMetrics]);
116147

117148
return (
118149
<div className="flex flex-col gap-6">

src/pages/cm/CMPanel.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,8 @@ const CMPanel: React.FC = () => {
176176
<CardContent className="text-sm text-muted">
177177
Set your <HintLabel termId="cognitocratic_measure">CM</HintLabel>{" "}
178178
multipliers for chambers you are not a member of. Chambers you belong
179-
to are blurred and not adjustable here.
179+
to are blurred and not adjustable here. If you submit a new number for
180+
the same chamber later, it replaces your previous submission.
180181
</CardContent>
181182
</Card>
182183

src/pages/feed/Feed.tsx

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ import { NoDataYetBar } from "@/components/NoDataYetBar";
1515
import { ToggleGroup } from "@/components/ToggleGroup";
1616
import { formatDateTime } from "@/lib/dateTime";
1717
import {
18+
apiClock,
1819
apiCourt,
1920
apiFeed,
2021
apiFactionCofounderInviteAccept,
2122
apiFactionCofounderInviteDecline,
2223
apiHuman,
24+
apiMyGovernance,
2325
apiProposalChamberPage,
2426
apiProposalFormationPage,
2527
apiProposalPoolPage,
@@ -93,8 +95,16 @@ const urgentEntityKey = (item: FeedItemDto) => {
9395
const isUrgentItemInteractable = (
9496
item: FeedItemDto,
9597
isGovernorActive: boolean,
98+
viewerAddress?: string,
9699
) => {
97100
if (item.actionable !== true) return false;
101+
if (item.stage === "build") {
102+
const viewer = viewerAddress?.trim().toLowerCase();
103+
const proposer = (item.proposerId ?? item.proposer ?? "")
104+
.trim()
105+
.toLowerCase();
106+
return Boolean(viewer && proposer && viewer === proposer);
107+
}
98108
if ((item.stage === "pool" || item.stage === "vote") && !isGovernorActive) {
99109
return false;
100110
}
@@ -104,9 +114,10 @@ const isUrgentItemInteractable = (
104114
const toUrgentItems = (
105115
items: FeedItemDto[],
106116
isGovernorActive: boolean,
117+
viewerAddress?: string,
107118
): FeedItemDto[] => {
108119
const filtered = items.filter((item) =>
109-
isUrgentItemInteractable(item, isGovernorActive),
120+
isUrgentItemInteractable(item, isGovernorActive, viewerAddress),
110121
);
111122
const deduped = new Map<string, FeedItemDto>();
112123
for (const item of filtered) {
@@ -175,15 +186,23 @@ const Feed: React.FC = () => {
175186
setChambersLoading(true);
176187
(async () => {
177188
try {
178-
const profile = await apiHuman(address);
189+
const [governance, profile, clock] = await Promise.all([
190+
apiMyGovernance(),
191+
apiHuman(address),
192+
apiClock(),
193+
]);
179194
if (!active) return;
180-
const chamberIds =
181-
profile.cmChambers?.map((chamber) => chamber.chamberId) ?? [];
195+
const tier = profile.tierProgress?.tier?.trim().toLowerCase() ?? "";
196+
const bootstrapGovernor =
197+
clock.currentEra === 0 && tier !== "" && tier !== "nominee";
198+
const chamberIds = governance.myChamberIds ?? [];
182199
const unique = Array.from(
183200
new Set(["general", ...chamberIds.map((id) => id.toLowerCase())]),
184201
);
185202
setChamberFilters(unique);
186-
setViewerGovernorActive(Boolean(profile.governorActive));
203+
setViewerGovernorActive(
204+
Boolean(profile.governorActive) || bootstrapGovernor,
205+
);
187206
} catch (error) {
188207
if (!active) return;
189208
setChamberFilters([]);
@@ -262,7 +281,11 @@ const Feed: React.FC = () => {
262281
}
263282
const filteredItems =
264283
feedScope === "urgent"
265-
? toUrgentItems(items, viewerGovernorActive)
284+
? toUrgentItems(
285+
items,
286+
viewerGovernorActive,
287+
auth.address ?? undefined,
288+
)
266289
: items;
267290
setFeedItems(filteredItems);
268291
setNextCursor(res.nextCursor ?? null);
@@ -339,13 +362,18 @@ const Feed: React.FC = () => {
339362
});
340363
const items =
341364
feedScope === "urgent"
342-
? toUrgentItems(res.items, viewerGovernorActive)
365+
? toUrgentItems(
366+
res.items,
367+
viewerGovernorActive,
368+
auth.address ?? undefined,
369+
)
343370
: res.items;
344371
setFeedItems((curr) => {
345372
if (feedScope === "urgent") {
346373
return toUrgentItems(
347374
[...(curr ?? []), ...items],
348375
viewerGovernorActive,
376+
auth.address ?? undefined,
349377
);
350378
}
351379
const existing = new Set((curr ?? []).map(feedItemKey));

0 commit comments

Comments
 (0)