diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index 8f86ce6..72d7702 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -228,6 +228,7 @@ export type ChamberVoteChoice = "yes" | "no" | "abstain"; export async function apiChamberVote(input: { proposalId: string; choice: ChamberVoteChoice; + score?: number; idempotencyKey?: string; }): Promise<{ ok: true; @@ -240,7 +241,45 @@ export async function apiChamberVote(input: { "/api/command", { type: "chamber.vote", - payload: { proposalId: input.proposalId, choice: input.choice }, + payload: { + proposalId: input.proposalId, + choice: input.choice, + ...(input.choice === "yes" && typeof input.score === "number" + ? { score: input.score } + : {}), + }, + idempotencyKey: input.idempotencyKey, + }, + input.idempotencyKey + ? { headers: { "idempotency-key": input.idempotencyKey } } + : undefined, + ); +} + +export async function apiChamberMultiplierSubmit(input: { + chamberId: string; + multiplierTimes10: number; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "chamber.multiplier.submit"; + chamberId: string; + submission: { multiplierTimes10: number }; + aggregate: { submissions: number; avgTimes10: number | null }; + applied: { + updated: boolean; + prevMultiplierTimes10: number; + nextMultiplierTimes10: number; + } | null; +}> { + return await apiPost( + "/api/command", + { + type: "chamber.multiplier.submit", + payload: { + chamberId: input.chamberId, + multiplierTimes10: input.multiplierTimes10, + }, idempotencyKey: input.idempotencyKey, }, input.idempotencyKey diff --git a/src/pages/cm/CMPanel.tsx b/src/pages/cm/CMPanel.tsx index 1f54ce6..6704d9a 100644 --- a/src/pages/cm/CMPanel.tsx +++ b/src/pages/cm/CMPanel.tsx @@ -12,7 +12,11 @@ import { HintLabel } from "@/components/Hint"; import { Surface } from "@/components/Surface"; import { PageHint } from "@/components/PageHint"; import { NoDataYetBar } from "@/components/NoDataYetBar"; -import { apiChambers } from "@/lib/apiClient"; +import { + apiChambers, + apiChamberMultiplierSubmit, + apiMyGovernance, +} from "@/lib/apiClient"; import type { ChamberDto } from "@/types/api"; const CMPanel: React.FC = () => { @@ -21,24 +25,44 @@ const CMPanel: React.FC = () => { current: number; suggested: number; member: boolean; + submissions: number; } > | null>(null); const [loadError, setLoadError] = useState(null); + const [submitError, setSubmitError] = useState(null); + const [submittingId, setSubmittingId] = useState(null); + const [submittingAll, setSubmittingAll] = useState(false); useEffect(() => { let active = true; (async () => { try { - const res = await apiChambers(); + const [chambersRes, governanceRes] = await Promise.allSettled([ + apiChambers(), + apiMyGovernance(), + ]); if (!active) return; + if (chambersRes.status !== "fulfilled") { + setChambers([]); + setLoadError( + chambersRes.reason?.message ?? "Failed to load chamber multipliers", + ); + return; + } + const myChamberIds = + governanceRes.status === "fulfilled" + ? governanceRes.value.myChamberIds + : []; + const items = chambersRes.value.items; setChambers( - res.items.map((chamber) => ({ + items.map((chamber) => ({ id: chamber.id, name: chamber.name, multiplier: chamber.multiplier, current: chamber.multiplier, suggested: chamber.multiplier, - member: chamber.id === "engineering" || chamber.id === "product", + member: myChamberIds.includes(chamber.id), + submissions: 0, })), ); setLoadError(null); @@ -61,7 +85,50 @@ const CMPanel: React.FC = () => { ); }; + const handleSubmit = async (chamberId: string) => { + if (!chambers) return; + const target = chambers.find((chamber) => chamber.id === chamberId); + if (!target) return; + if (target.member) return; + setSubmittingId(chamberId); + setSubmitError(null); + try { + const result = await apiChamberMultiplierSubmit({ + chamberId, + multiplierTimes10: Math.round(target.suggested * 10), + }); + setChambers((prev) => + prev + ? prev.map((chamber) => { + if (chamber.id !== chamberId) return chamber; + const nextTimes10 = + result.applied?.nextMultiplierTimes10 ?? + result.aggregate.avgTimes10 ?? + Math.round(chamber.suggested * 10); + return { + ...chamber, + current: nextTimes10 / 10, + submissions: result.aggregate.submissions, + }; + }) + : prev, + ); + } catch (error) { + setSubmitError((error as Error).message); + } finally { + setSubmittingId(null); + } + }; + const nonMemberSuggestions = (chambers ?? []).filter((c) => !c.member); + const handleSubmitAll = async () => { + if (!chambers || nonMemberSuggestions.length === 0) return; + setSubmittingAll(true); + for (const chamber of nonMemberSuggestions) { + await handleSubmit(chamber.id); + } + setSubmittingAll(false); + }; return (
@@ -79,6 +146,11 @@ const CMPanel: React.FC = () => { {chambers !== null && chambers.length === 0 && !loadError ? ( ) : null} + {submitError ? ( + + CM submission failed: {submitError} + + ) : null} Overview @@ -126,6 +198,17 @@ const CMPanel: React.FC = () => { className="w-full" />
+
+ {chamber.submissions} submissions + +
{chamber.member && (

You cannot set M for chambers you belong to. @@ -138,8 +221,12 @@ const CMPanel: React.FC = () => {

-
diff --git a/src/pages/proposals/ProposalChamber.tsx b/src/pages/proposals/ProposalChamber.tsx index e40f8e5..0149d2e 100644 --- a/src/pages/proposals/ProposalChamber.tsx +++ b/src/pages/proposals/ProposalChamber.tsx @@ -4,6 +4,7 @@ import { StatTile } from "@/components/StatTile"; import { PageHint } from "@/components/PageHint"; import { ProposalPageHeader } from "@/components/ProposalPageHeader"; import { VoteButton } from "@/components/VoteButton"; +import { Input } from "@/components/primitives/input"; import { ProposalInvisionInsightCard, ProposalSummaryCard, @@ -29,6 +30,7 @@ const ProposalChamber: React.FC = () => { const [submitting, setSubmitting] = useState(false); const [timeline, setTimeline] = useState([]); const [timelineError, setTimelineError] = useState(null); + const [yesScore, setYesScore] = useState(5); const loadPage = useCallback(async () => { if (!id) return; @@ -117,12 +119,19 @@ const ProposalChamber: React.FC = () => { .map((v) => Number(v.trim())); const openSlots = Math.max(totalSlots - filledSlots, 0); - const handleVote = async (choice: "yes" | "no" | "abstain") => { + const handleVote = async ( + choice: "yes" | "no" | "abstain", + score?: number, + ) => { if (!id || submitting) return; setSubmitting(true); setSubmitError(null); try { - await apiChamberVote({ proposalId: id, choice }); + await apiChamberVote({ + proposalId: id, + choice, + score: choice === "yes" ? score : undefined, + }); await loadPage(); } catch (error) { setSubmitError((error as Error).message); @@ -145,8 +154,27 @@ const ProposalChamber: React.FC = () => { tone="accent" label="Vote yes" disabled={submitting} - onClick={() => handleVote("yes")} + onClick={() => handleVote("yes", yesScore)} /> +
+ + CM score + + { + const next = Number(event.target.value); + if (Number.isFinite(next)) { + setYesScore(Math.min(Math.max(Math.round(next), 1), 10)); + } + }} + className="h-8 w-16" + /> +