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
41 changes: 40 additions & 1 deletion src/lib/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
99 changes: 93 additions & 6 deletions src/pages/cm/CMPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -21,24 +25,44 @@ const CMPanel: React.FC = () => {
current: number;
suggested: number;
member: boolean;
submissions: number;
}
> | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submittingId, setSubmittingId] = useState<string | null>(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);
Expand All @@ -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 (
<div className="flex flex-col gap-6">
Expand All @@ -79,6 +146,11 @@ const CMPanel: React.FC = () => {
{chambers !== null && chambers.length === 0 && !loadError ? (
<NoDataYetBar label="chambers" />
) : null}
{submitError ? (
<Card className="border-dashed px-4 py-6 text-center text-sm text-destructive">
CM submission failed: {submitError}
</Card>
) : null}
<Card>
<CardHeader className="pb-2">
<CardTitle>Overview</CardTitle>
Expand Down Expand Up @@ -126,6 +198,17 @@ const CMPanel: React.FC = () => {
className="w-full"
/>
</div>
<div className="flex items-center justify-between text-xs text-muted">
<span>{chamber.submissions} submissions</span>
<Button
size="sm"
variant="ghost"
disabled={chamber.member || submittingId === chamber.id}
onClick={() => handleSubmit(chamber.id)}
>
{submittingId === chamber.id ? "Submitting…" : "Submit"}
</Button>
</div>
{chamber.member && (
<p className="text-xs text-muted">
You cannot set M for chambers you belong to.
Expand All @@ -138,8 +221,12 @@ const CMPanel: React.FC = () => {
</Card>

<div className="flex justify-end">
<Button size="sm" disabled={nonMemberSuggestions.length === 0}>
Submit suggestions
<Button
size="sm"
disabled={nonMemberSuggestions.length === 0 || submittingAll}
onClick={handleSubmitAll}
>
{submittingAll ? "Submitting…" : "Submit suggestions"}
</Button>
</div>
</div>
Expand Down
34 changes: 31 additions & 3 deletions src/pages/proposals/ProposalChamber.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +30,7 @@ const ProposalChamber: React.FC = () => {
const [submitting, setSubmitting] = useState(false);
const [timeline, setTimeline] = useState<ProposalTimelineItemDto[]>([]);
const [timelineError, setTimelineError] = useState<string | null>(null);
const [yesScore, setYesScore] = useState(5);

const loadPage = useCallback(async () => {
if (!id) return;
Expand Down Expand Up @@ -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);
Expand All @@ -145,8 +154,27 @@ const ProposalChamber: React.FC = () => {
tone="accent"
label="Vote yes"
disabled={submitting}
onClick={() => handleVote("yes")}
onClick={() => handleVote("yes", yesScore)}
/>
<div className="flex items-center gap-2 rounded-full border border-border bg-panel px-3 py-2 text-sm text-text">
<span className="text-xs font-semibold text-muted uppercase">
CM score
</span>
<Input
type="number"
min={1}
max={10}
step={1}
value={yesScore}
onChange={(event) => {
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"
/>
</div>
<VoteButton
tone="destructive"
label="Vote no"
Expand Down