From f18a0564668dd32fac34cc5a1ff04ce26eb4dea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valent=C3=ADn=20Quiroz=20de=20la=20Sierra?= <85706426+vqsierra@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:09:21 -0500 Subject: [PATCH] Add "I'm done voting" and mvote status - Web app: an "I'm done voting" button locks that participant's voting with a thank-you and a "Keep voting" link to undo it. The closed notice now reads "Voting has ended. The counts below are final." - firestore.rules: a participant may change only their own done flag (true or false); votes and extra dots are refused while done. - CLI: mvote status prints how many participants joined, how many are done, and how many dots are placed. - Tests: web app, CLI and emulator tests for the done flag. --- cli/README.md | 2 ++ cli/mvote/cli.py | 16 ++++++++++ cli/mvote/firebase_store.py | 26 +++++++++++++++++ cli/tests/test_cli.py | 11 +++++++ cli/tests/test_firebase_store.py | 23 +++++++++++++++ docs/GETTING_STARTED.md | 8 ++++- firestore.rules | 15 ++++++++-- webapp/rules/firestore.rules.test.js | 32 ++++++++++++++++++-- webapp/src/components/VotingBoard.jsx | 34 +++++++++++++++++++--- webapp/src/components/VotingBoard.test.jsx | 29 ++++++++++++++++++ webapp/src/data/firestoreBoardStore.js | 14 +++++++-- webapp/src/data/index.js | 2 +- webapp/src/data/mockBoardStore.js | 9 +++++- webapp/src/styles.css | 3 +- 14 files changed, 208 insertions(+), 16 deletions(-) diff --git a/cli/README.md b/cli/README.md index 45ee72f..0846a5e 100644 --- a/cli/README.md +++ b/cli/README.md @@ -35,6 +35,8 @@ export GOOGLE_APPLICATION_CREDENTIALS=/path/to/serviceAccountKey.json The link uses a placeholder domain until you tell the tool where the web app lives. Pass `--base-url` or set `MVOTE_WEBAPP_URL`. Use `--qr-out path.png` to save the QR code as an image instead of printing it. +During a session, `mvote status --board-id ` shows how many participants joined, how many tapped "I'm done voting", and how many dots are placed. + When the session ends, close it to new votes and pull the results: ```bash diff --git a/cli/mvote/cli.py b/cli/mvote/cli.py index e170bd7..eb51f05 100644 --- a/cli/mvote/cli.py +++ b/cli/mvote/cli.py @@ -115,6 +115,22 @@ def results(board_id: str, out_path: str | None): _write_results(sys.stdout, rows) +@main.command("status") +@click.option("--board-id", required=True, help="Id of a published board.") +def status(board_id: str): + """Show how many participants joined, how many are done, and dots placed.""" + try: + s = firebase_store.board_status(board_id) + except ValueError as e: + click.echo(str(e), err=True) + sys.exit(1) + state = "closed" if s["closed"] else "open" + click.echo( + f"Board '{board_id}' is {state}: {s['done']} of {s['participants']} participants done, " + f"{s['dots_placed']} dots placed." + ) + + @main.command("close") @click.option("--board-id", required=True, help="Id of a published board.") def close(board_id: str): diff --git a/cli/mvote/firebase_store.py b/cli/mvote/firebase_store.py index 06b1e13..6357b43 100644 --- a/cli/mvote/firebase_store.py +++ b/cli/mvote/firebase_store.py @@ -18,6 +18,7 @@ BOARDS_COLLECTION = "boards" VOTES_SUBCOLLECTION = "votes" +PARTICIPANTS_SUBCOLLECTION = "participants" def get_client() -> Any: @@ -93,6 +94,31 @@ def set_board_closed(board_id: str, closed: bool, db: Any = None) -> None: board_ref.update({"closed": closed}) +def board_status(board_id: str, db: Any = None) -> dict: + """Summarize a live board for the facilitator. + + Returns ``closed``, ``participants`` (how many joined), ``done`` (how + many tapped "I'm done voting") and ``dots_placed`` (all votes so far). + + Raises: + ValueError: if no board with this id exists. + """ + db = db or get_client() + board_ref = db.collection(BOARDS_COLLECTION).document(board_id) + snapshot = board_ref.get() + if not snapshot.exists: + raise ValueError(f"Board '{board_id}' not found.") + participants = [ + doc.to_dict() or {} for doc in board_ref.collection(PARTICIPANTS_SUBCOLLECTION).stream() + ] + return { + "closed": bool((snapshot.to_dict() or {}).get("closed", False)), + "participants": len(participants), + "done": sum(1 for p in participants if p.get("done")), + "dots_placed": sum(fetch_vote_counts(board_id, db=db).values()), + } + + def load_board(board_id: str, db: Any = None) -> Board: """Fetch a previously-saved board definition from Firestore. diff --git a/cli/tests/test_cli.py b/cli/tests/test_cli.py index 36603e2..19e344c 100644 --- a/cli/tests/test_cli.py +++ b/cli/tests/test_cli.py @@ -135,3 +135,14 @@ def raise_not_found(board_id, closed, db=None): result = CliRunner().invoke(main, ["close", "--board-id", "nope"]) assert result.exit_code == 1 assert "not found" in result.output + + +def test_status_prints_summary(monkeypatch): + monkeypatch.setattr( + "mvote.cli.firebase_store.board_status", + lambda board_id, db=None: {"closed": False, "participants": 13, "done": 9, "dots_placed": 142}, + ) + result = CliRunner().invoke(main, ["status", "--board-id", "b1"]) + assert result.exit_code == 0 + assert "is open: 9 of 13 participants done, 142 dots placed." in result.output + diff --git a/cli/tests/test_firebase_store.py b/cli/tests/test_firebase_store.py index 874f140..f9aa840 100644 --- a/cli/tests/test_firebase_store.py +++ b/cli/tests/test_firebase_store.py @@ -166,3 +166,26 @@ def test_set_board_closed_and_reopen(tmp_path): def test_set_board_closed_missing_board(): with pytest.raises(ValueError, match="not found"): firebase_store.set_board_closed("nope", True, db=FakeFirestoreClient()) + + +def test_board_status_counts_participants_done_and_dots(tmp_path): + db = FakeFirestoreClient() + config = tmp_path / "items.json" + config.write_text(json.dumps([{"id": "a", "label": "A"}, {"id": "b", "label": "B"}])) + firebase_store.save_board(create_board(config, title="T", board_id="b1"), db=db) + board = db.collection("boards").document("b1") + board.collection("votes").document("a").set({"count": 5}) + board.collection("votes").document("b").set({"count": 2}) + board.collection("participants").document("p1").set({"remaining_dots": 1, "done": True}) + board.collection("participants").document("p2").set({"remaining_dots": 8}) + board.collection("participants").document("p3").set({"remaining_dots": 0, "done": False}) + + assert firebase_store.board_status("b1", db=db) == { + "closed": False, "participants": 3, "done": 1, "dots_placed": 7, + } + + +def test_board_status_missing_board(): + with pytest.raises(ValueError, match="not found"): + firebase_store.board_status("nope", db=FakeFirestoreClient()) + diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 9ddd386..7b8eb66 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -37,7 +37,13 @@ This guide is for when you have a session coming up and want to know what to do. 3. **Share the link or QR code.** Participants don't need an account or an app. A browser is enough. -4. **Participants vote live.** Each person gets 8 dots and can put more than one on a favorite item. After they use all 8, they can ask for 4 more. Votes update in real time for everyone. +4. **Participants vote live.** Each person gets 8 dots and can put more than one on a favorite item. After they use all 8, they can ask for 4 more. Votes update in real time for everyone. When someone has finished, they tap **I'm done voting**, which locks their voting until they tap **Keep voting**. To see how the room is doing: + + ```bash + mvote status --board-id + ``` + + It prints something like `9 of 13 participants done, 142 dots placed`, so you know when it's safe to close. 5. **Close voting when the session ends,** then pull the results: diff --git a/firestore.rules b/firestore.rules index 1aca78e..44836f9 100644 --- a/firestore.rules +++ b/firestore.rules @@ -68,14 +68,15 @@ service cloud.firestore { // it the ability to spend their dots. A participant may create // their own starting record and can only update it via the two // legitimate transitions — spending one dot, or re-upping by 4 - // once empty. + // once empty — or mark themselves done (or not). A participant who + // is done can't spend dots until they choose to keep voting. allow get: if true; allow list: if false; allow create: if boardOpen() && request.resource.data.remaining_dots == 8 && request.resource.data.votes == {} && request.resource.data.keys().hasOnly(['remaining_dots', 'votes']); - allow update: if boardOpen() && (isDotSpend() || isReup()); + allow update: if boardOpen() && (isDotSpend() || isReup() || isDoneToggle()); allow delete: if false; // One dot goes to one item, and that item's count goes up by one in @@ -84,6 +85,7 @@ service cloud.firestore { let item = request.resource.data.last_item; let votePath = /databases/$(database)/documents/boards/$(boardId)/votes/$(item); return item is string + && !resource.data.get('done', false) && resource.data.remaining_dots > 0 && request.resource.data.remaining_dots == resource.data.remaining_dots - 1 && request.resource.data.diff(resource.data).affectedKeys() @@ -94,8 +96,15 @@ service cloud.firestore { && getAfter(votePath).data.get('last_voter', null) == participantId; } + // "I'm done voting" and "Keep voting" change only the done flag. + function isDoneToggle() { + return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['done']) + && request.resource.data.done is bool; + } + function isReup() { - return resource.data.remaining_dots == 0 + return !resource.data.get('done', false) + && resource.data.remaining_dots == 0 && request.resource.data.remaining_dots == 4 && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['remaining_dots']); } diff --git a/webapp/rules/firestore.rules.test.js b/webapp/rules/firestore.rules.test.js index 3b1a0b5..1672dba 100644 --- a/webapp/rules/firestore.rules.test.js +++ b/webapp/rules/firestore.rules.test.js @@ -20,13 +20,15 @@ beforeAll(async () => { afterAll(() => env.cleanup()); -async function seed({ closed = false, dots = 8, votes = {} } = {}) { +async function seed({ closed = false, dots = 8, votes = {}, done } = {}) { await env.withSecurityRulesDisabled(async (context) => { const admin = context.firestore(); await setDoc(doc(admin, 'boards', BOARD), { title: 'T', items: [], closed }); await setDoc(doc(admin, 'boards', BOARD, 'votes', 'a'), { count: 0 }); await setDoc(doc(admin, 'boards', BOARD, 'votes', 'b'), { count: 0 }); - await setDoc(doc(admin, 'boards', BOARD, 'participants', 'p1'), { remaining_dots: dots, votes }); + await setDoc(doc(admin, 'boards', BOARD, 'participants', 'p1'), { + remaining_dots: dots, votes, ...(done === undefined ? {} : { done }), + }); }); } @@ -127,6 +129,32 @@ describe('a closed board', () => { }); }); +describe("I'm done voting", () => { + it('lets a participant mark themselves done and then keep voting', async () => { + await seed(); + await assertSucceeds(updateDoc(participant(), { done: true })); + await assertSucceeds(updateDoc(participant(), { done: false })); + await assertSucceeds(castVote('a')); + }); + + it('refuses votes and extra dots while done', async () => { + await seed({ done: true }); + await assertFails(castVote('a')); + await env.withSecurityRulesDisabled((c) => updateDoc(doc(c.firestore(), 'boards', BOARD, 'participants', 'p1'), { remaining_dots: 0 })); + await assertFails(updateDoc(participant(), { remaining_dots: 4 })); + }); + + it('refuses a done change that also changes dots', async () => { + await seed(); + await assertFails(updateDoc(participant(), { done: true, remaining_dots: 20 })); + }); + + it('refuses a done value that is not true or false', async () => { + await seed(); + await assertFails(updateDoc(participant(), { done: 'yes' })); + }); +}); + describe('joining', () => { it('allows a new participant with 8 dots and no votes', async () => { await seed(); diff --git a/webapp/src/components/VotingBoard.jsx b/webapp/src/components/VotingBoard.jsx index e8e46d3..7eee5f4 100644 --- a/webapp/src/components/VotingBoard.jsx +++ b/webapp/src/components/VotingBoard.jsx @@ -7,7 +7,7 @@ import LoaderInline from '@jetbrains/ring-ui-built/components/loader-inline/load import ProgressBar from '@jetbrains/ring-ui-built/components/progress-bar/progress-bar'; import Text from '@jetbrains/ring-ui-built/components/text/text'; import { - castVote, joinParticipant, loadBoard, requestMoreDots, subscribeBoardStatus, subscribeVotes, + castVote, joinParticipant, loadBoard, requestMoreDots, setDone, subscribeBoardStatus, subscribeVotes, } from '../data/index.js'; import { getParticipantId } from '../participantId.js'; import ItemCard from './ItemCard.jsx'; @@ -91,6 +91,12 @@ export default function VotingBoard({ boardId }) { setParticipant((prev) => ({ ...prev, remainingDots: updated.remainingDots })); }); + const handleDone = (done) => + runWrite(async (participantId) => { + await setDone(boardId, participantId, done); + setParticipant((prev) => ({ ...prev, done })); + }); + if (error && !loaded) { return (
@@ -119,6 +125,7 @@ export default function VotingBoard({ boardId }) { const dotsLeftText = `${participant.remainingDots === 1 ? 'dot' : 'dots'} left`; const dotsPlaced = Object.values(participant.votes).reduce((sum, n) => sum + n, 0); const totalDots = dotsPlaced + participant.remainingDots; + const done = Boolean(participant.done); return (
@@ -134,7 +141,7 @@ export default function VotingBoard({ boardId }) { - {participant.remainingDots <= 0 && !closed && ( + {participant.remainingDots <= 0 && !closed && !done && ( + )} - This session is closed. Voting has ended; the counts below are final. + This session is closed. Voting has ended. The counts below are final. + + )} + + {done && !closed && ( +
+ + Thanks, your votes are in.{' '} + +
)} @@ -169,7 +195,7 @@ export default function VotingBoard({ boardId }) { voteCount={votes[item.id]?.count || 0} placements={votes[item.id]?.placements} yourVotes={participant.votes[item.id] || 0} - disabled={closed || participant.remainingDots <= 0 || pending} + disabled={closed || done || participant.remainingDots <= 0 || pending} onVote={() => handleVote(item.id)} /> ))} diff --git a/webapp/src/components/VotingBoard.test.jsx b/webapp/src/components/VotingBoard.test.jsx index 4d69035..10d9082 100644 --- a/webapp/src/components/VotingBoard.test.jsx +++ b/webapp/src/components/VotingBoard.test.jsx @@ -119,4 +119,33 @@ describe('VotingBoard', () => { await waitFor(() => expect(screen.queryByText(/This session is closed/)).not.toBeInTheDocument()); expect(screen.getByLabelText('Add a dot to School connectedness')).not.toBeDisabled(); }); + + it("locks voting with a thank-you when the participant is done, until they keep voting", async () => { + render(); + await screen.findByText('School connectedness'); + const voteButton = screen.getByLabelText('Add a dot to School connectedness'); + fireEvent.click(voteButton); + await waitFor(() => expect(screen.getByLabelText('7 dots left')).toBeInTheDocument()); + + fireEvent.click(screen.getByText("I'm done voting")); + + expect(await screen.findByText(/Thanks, your votes are in/)).toBeInTheDocument(); + expect(voteButton).toBeDisabled(); + expect(screen.queryByText("I'm done voting")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText('Keep voting')); + + await waitFor(() => expect(screen.queryByText(/Thanks, your votes are in/)).not.toBeInTheDocument()); + expect(voteButton).not.toBeDisabled(); + expect(screen.getByText("I'm done voting")).toBeInTheDocument(); + }); + + it('hides the done button once the session is closed', async () => { + const boardId = uniqueBoardId(); + render(); + await screen.findByText("I'm done voting"); + act(() => setBoardClosed(boardId, true)); + await screen.findByText(/This session is closed/); + expect(screen.queryByText("I'm done voting")).not.toBeInTheDocument(); + }); }); diff --git a/webapp/src/data/firestoreBoardStore.js b/webapp/src/data/firestoreBoardStore.js index 6c751a5..3312fb1 100644 --- a/webapp/src/data/firestoreBoardStore.js +++ b/webapp/src/data/firestoreBoardStore.js @@ -5,6 +5,7 @@ import { onSnapshot, runTransaction, setDoc, + updateDoc, } from 'firebase/firestore'; import { db } from '../firebase'; import { TRACK_PARTICIPANT_COLORS } from '../config.js'; @@ -46,11 +47,11 @@ export async function joinParticipant(boardId, participantId) { const snapshot = await getDoc(participantRef); if (snapshot.exists()) { const data = snapshot.data(); - return { remainingDots: data.remaining_dots, votes: data.votes || {} }; + return { remainingDots: data.remaining_dots, votes: data.votes || {}, done: Boolean(data.done) }; } const initial = { remaining_dots: STARTING_DOTS, votes: {} }; await setDoc(participantRef, initial); - return { remainingDots: initial.remaining_dots, votes: initial.votes }; + return { remainingDots: initial.remaining_dots, votes: initial.votes, done: false }; } export async function castVote(boardId, participantId, itemId) { @@ -91,7 +92,7 @@ export async function castVote(boardId, participantId, itemId) { tx.update(participantRef, updatedParticipant); tx.update(voteRef, updatedVote); - return { remainingDots: updatedParticipant.remaining_dots, votes: updatedParticipant.votes }; + return { remainingDots: updatedParticipant.remaining_dots, votes: updatedParticipant.votes, done: false }; }); } @@ -112,3 +113,10 @@ export async function requestMoreDots(boardId, participantId) { return { remainingDots: updatedDots, votes: participant.votes || {} }; }); } + +// "I'm done voting" (true) or "Keep voting" (false). The facilitator sees the +// done count with `mvote status`. +export async function setDone(boardId, participantId, done) { + await updateDoc(doc(db, 'boards', boardId, 'participants', participantId), { done }); + return done; +} diff --git a/webapp/src/data/index.js b/webapp/src/data/index.js index 0e4d221..0de0f7e 100644 --- a/webapp/src/data/index.js +++ b/webapp/src/data/index.js @@ -6,4 +6,4 @@ import * as mockBoardStore from './mockBoardStore.js'; const store = USE_MOCK ? mockBoardStore : firestoreBoardStore; -export const { loadBoard, subscribeBoardStatus, subscribeVotes, joinParticipant, castVote, requestMoreDots } = store; +export const { loadBoard, subscribeBoardStatus, subscribeVotes, joinParticipant, castVote, requestMoreDots, setDone } = store; diff --git a/webapp/src/data/mockBoardStore.js b/webapp/src/data/mockBoardStore.js index 6e5cfa2..d037edf 100644 --- a/webapp/src/data/mockBoardStore.js +++ b/webapp/src/data/mockBoardStore.js @@ -78,7 +78,7 @@ export function subscribeVotes(boardId, callback) { export async function joinParticipant(boardId, participantId) { const key = participantKey(boardId, participantId); if (!participants.has(key)) { - participants.set(key, { remainingDots: STARTING_DOTS, votes: {} }); + participants.set(key, { remainingDots: STARTING_DOTS, votes: {}, done: false }); } return { ...participants.get(key) }; } @@ -90,6 +90,7 @@ export async function castVote(boardId, participantId, itemId) { if (!participant || participant.remainingDots <= 0) { throw new Error('No dots remaining.'); } + if (participant.done) throw new Error('Tap "Keep voting" to place more dots.'); participant.remainingDots -= 1; participant.votes[itemId] = (participant.votes[itemId] || 0) + 1; const board = ensureBoard(boardId); @@ -111,3 +112,9 @@ export async function requestMoreDots(boardId, participantId) { participant.remainingDots += REUP_DOTS; return { ...participant }; } + +export async function setDone(boardId, participantId, done) { + assertOpen(boardId); + participants.get(participantKey(boardId, participantId)).done = done; + return done; +} diff --git a/webapp/src/styles.css b/webapp/src/styles.css index 42cd143..b7ece46 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -48,7 +48,8 @@ h1.voting-board__title { font-weight: 600; } -.voting-board__more { +.voting-board__more, +.voting-board__done { display: block; margin: 0 1rem 0.75rem auto; }