Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -90,6 +91,7 @@ const AppRoutes: React.FC = () => {
<Route path="proposals/:id/pp" element={<ProposalPP />} />
<Route path="proposals/:id/chamber" element={<ProposalChamber />} />
<Route path="proposals/:id/formation" element={<ProposalFormation />} />
<Route path="proposals/:id/finished" element={<ProposalFinished />} />
<Route path="chambers" element={<Chambers />} />
<Route path="chambers/:id" element={<Chamber />} />
<Route path="formation" element={<Formation />} />
Expand Down
7 changes: 6 additions & 1 deletion src/components/ProposalPageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { StatTile } from "@/components/StatTile";
type ProposalPageHeaderProps = {
title: string;
stage: ProposalStage;
showFormationStage?: boolean;
chamber: string;
proposer: string;
children?: ReactNode;
Expand All @@ -18,14 +19,18 @@ type ProposalPageHeaderProps = {
export function ProposalPageHeader({
title,
stage,
showFormationStage = true,
chamber,
proposer,
children,
}: ProposalPageHeaderProps) {
return (
<section className="space-y-4">
<h1 className="text-center text-2xl font-semibold text-text">{title}</h1>
<ProposalStageBar current={stage} />
<ProposalStageBar
current={stage}
showFormationStage={showFormationStage}
/>
<div className="grid gap-3 sm:grid-cols-2">
<StatTile
label="Chamber"
Expand Down
15 changes: 12 additions & 3 deletions src/components/ProposalStageBar.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import React from "react";
import { HintLabel } from "@/components/Hint";

export type ProposalStage = "draft" | "pool" | "vote" | "build";
export type ProposalStage = "draft" | "pool" | "vote" | "build" | "passed";

type ProposalStageBarProps = {
current: ProposalStage;
showFormationStage?: boolean;
className?: string;
};

export const ProposalStageBar: React.FC<ProposalStageBarProps> = ({
current,
showFormationStage = true,
className,
}) => {
const stages: {
const allStages: {
key: ProposalStage;
label: string;
render?: React.ReactNode;
Expand All @@ -33,7 +35,12 @@ export const ProposalStageBar: React.FC<ProposalStageBarProps> = ({
label: "Formation",
render: <HintLabel termId="formation">Formation</HintLabel>,
},
{ key: "passed", label: "Passed" },
];
const stages = allStages.filter(
(stage) =>
stage.key !== "build" || showFormationStage || current === "build",
);

return (
<div className={["flex gap-2", className].filter(Boolean).join(" ")}>
Expand All @@ -46,7 +53,9 @@ export const ProposalStageBar: React.FC<ProposalStageBarProps> = ({
? "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 (
<div
key={stage.key}
Expand Down
1 change: 1 addition & 0 deletions src/components/StageChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const chipClasses: Record<StageChipKind, string> = {
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",
Expand Down
10 changes: 9 additions & 1 deletion src/lib/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@ export async function apiFormationMilestoneVote(input: {
projectState:
| "active"
| "awaiting_milestone_vote"
| "suspended"
| "canceled"
| "ready_to_finish"
| "completed";
pendingMilestoneIndex: number | null;
Expand Down Expand Up @@ -614,6 +614,14 @@ export async function apiProposalFormationPage(
);
}

export async function apiProposalFinishedPage(
id: string,
): Promise<FormationProposalPageDto> {
return await apiGet<FormationProposalPageDto>(
`/api/proposals/${id}/finished`,
);
}

export async function apiCourts(): Promise<GetCourtsResponse> {
return await apiGet<GetCourtsResponse>("/api/courts");
}
Expand Down
10 changes: 8 additions & 2 deletions src/lib/dtoParsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 4 additions & 7 deletions src/lib/proposalSubmitErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
65 changes: 48 additions & 17 deletions src/pages/chambers/Chambers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

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

const Chambers: React.FC = () => {
const [chambers, setChambers] = useState<ChamberDto[] | null>(null);
const [activeGovernors, setActiveGovernors] = useState<number | null>(null);
const [globalMetrics, setGlobalMetrics] = useState<{
governors: number;
activeGovernors: number;
totalAcm: number;
} | null>(null);
const [currentEra, setCurrentEra] = useState<number | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [filters, setFilters] = useState<{
Expand All @@ -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") {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 (
<div className="flex flex-col gap-6">
Expand Down
3 changes: 2 additions & 1 deletion src/pages/cm/CMPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@ const CMPanel: React.FC = () => {
<CardContent className="text-sm text-muted">
Set your <HintLabel termId="cognitocratic_measure">CM</HintLabel>{" "}
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.
</CardContent>
</Card>

Expand Down
42 changes: 35 additions & 7 deletions src/pages/feed/Feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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<string, FeedItemDto>();
for (const item of filtered) {
Expand Down Expand Up @@ -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([]);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down
Loading