/* P.E.S.T Web App — Votes (custom polls, quorum, %, runtime, past votes) */
function voteTotals(v) {
const total = Object.values(v.tally || {}).reduce((a, b) => a + b, 0);
return total;
}
function fmtLeft(ms) {
if (ms <= 0) return "ended";
const d = Math.floor(ms / 86400000);
const h = Math.floor((ms % 86400000) / 3600000);
if (d > 0) return d + "d " + h + "h left";
const m = Math.floor((ms % 3600000) / 60000);
return h + "h " + m + "m left";
}
function voteOutcome(v, eligible) {
const total = voteTotals(v);
const quorum = total >= v.threshold;
let winner = null, max = -1;
v.options.forEach((o) => { const c = v.tally[o.id] || 0; if (c > max) { max = c; winner = o; } });
const passed = quorum && winner && winner.id === v.options[0].id;
return { total, quorum, winner, passed };
}
function VoteCard({ v }) {
const store = useStore();
const eligible = store.members.length;
const total = voteTotals(v);
const myChoice = store.myVotes[v.id];
const expired = v.closed || v.endsAt <= Date.now();
const revealed = !!myChoice || expired;
const out = voteOutcome(v, eligible);
const stateChip = v.closed || expired
? (out.passed ? Passed : {out.quorum ? "Rejected" : "No quorum"})
: ● Live;
const cast = (optId) => {
if (revealed) return;
store.castVote(v.id, optId);
toast("Vote cast");
};
return (
{total} / {eligible} votes
Threshold {v.threshold}
{v.closed || expired ? "Ended " + window.timeAgo(v.endsAt) : fmtLeft(v.endsAt - Date.now())}
Quorum {out.quorum ? "reached" : "not reached"}
{Math.round((total / eligible) * 100)}% turnout
{v.options.map((o) => {
const c = v.tally[o.id] || 0;
const pct = total > 0 ? Math.round((c / total) * 100) : 0;
const chosen = myChoice === o.id;
const isWinner = revealed && out.winner && out.winner.id === o.id;
return (
);
})}
{revealed
? (myChoice ?
You voted · {v.options.find((o) => o.id === myChoice)?.label} :
Voting closed)
:
Pick an option to cast your vote}
{store.user.isAdmin && !v.closed && (
)}
);
}
function Votes() {
const store = useStore();
const [tab, setTab] = useState("active");
const [creating, setCreating] = useState(false);
const now = Date.now();
const active = store.votes.filter((v) => !v.closed && v.endsAt > now);
const past = store.votes.filter((v) => v.closed || v.endsAt <= now);
const list = tab === "active" ? active : past;
return (
Governance
Hive votes
Custom polls decided by the Core. One vote per member · {store.members.length} eligible voters.
{store.user.isAdmin &&
}
{list.length === 0
?
No {tab} votes.
:
{list.map((v) => )}
}
{creating &&
setCreating(false)} />}
);
}
function CreateVoteModal({ onClose }) {
const store = useStore();
const [title, setTitle] = useState("");
const [desc, setDesc] = useState("");
const [options, setOptions] = useState(["Approve", "Reject"]);
const [threshold, setThreshold] = useState(Math.ceil(store.members.length / 2) + 1);
const [days, setDays] = useState(3);
useEffect(() => {
const k = (e) => e.key === "Escape" && onClose();
window.addEventListener("keydown", k);
return () => window.removeEventListener("keydown", k);
}, []);
const setOpt = (i, val) => setOptions((o) => o.map((x, k) => k === i ? val : x));
const addOpt = () => setOptions((o) => o.length < 6 ? [...o, ""] : o);
const delOpt = (i) => setOptions((o) => o.length > 2 ? o.filter((_, k) => k !== i) : o);
const save = () => {
if (!title.trim()) { toast("Add a title", "err"); return; }
const opts = options.map((l) => l.trim()).filter(Boolean);
if (opts.length < 2) { toast("Add at least 2 options", "err"); return; }
const id = title.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 24) + "-" + Math.random().toString(36).slice(2, 5);
const vote = {
id, title: title.trim(), desc: desc.trim(),
options: opts.map((l, i) => ({ id: "o" + i + "-" + Math.random().toString(36).slice(2, 5), label: l })),
tally: {}, threshold: Number(threshold) || 1,
createdAt: Date.now(), endsAt: Date.now() + (Number(days) || 1) * 86400000, closed: false,
};
store.addVote(vote);
toast("Vote created");
onClose();
};
return (
e.stopPropagation()}>
New vote
setTitle(e.target.value)} placeholder="What are we deciding?" />
{options.map((o, i) => (
setOpt(i, e.target.value)} placeholder={"Option " + (i + 1)} />
))}
{options.length < 6 &&
}
);
}
window.Votes = Votes;