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 cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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
Expand Down
16 changes: 16 additions & 0 deletions cli/mvote/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
26 changes: 26 additions & 0 deletions cli/mvote/firebase_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

BOARDS_COLLECTION = "boards"
VOTES_SUBCOLLECTION = "votes"
PARTICIPANTS_SUBCOLLECTION = "participants"


def get_client() -> Any:
Expand Down Expand Up @@ -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.

Expand Down
11 changes: 11 additions & 0 deletions cli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

23 changes: 23 additions & 0 deletions cli/tests/test_firebase_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())

8 changes: 7 additions & 1 deletion docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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:

Expand Down
15 changes: 12 additions & 3 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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']);
}
Expand Down
32 changes: 30 additions & 2 deletions webapp/rules/firestore.rules.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
});
});
}

Expand Down Expand Up @@ -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();
Expand Down
34 changes: 30 additions & 4 deletions webapp/src/components/VotingBoard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<main className="page-centered">
Expand Down Expand Up @@ -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 (
<main className="voting-board">
Expand All @@ -134,7 +141,7 @@ export default function VotingBoard({ boardId }) {
<span aria-hidden="true">{dotsLeftText}</span>
</div>
</div>
{participant.remainingDots <= 0 && !closed && (
{participant.remainingDots <= 0 && !closed && !done && (
<Button
primary
height={ControlsHeight.L}
Expand All @@ -145,6 +152,16 @@ export default function VotingBoard({ boardId }) {
Request more dots (+4)
</Button>
)}
{!closed && !done && (
<Button
height={ControlsHeight.L}
className="touch-button voting-board__done"
onClick={() => handleDone(true)}
disabled={pending}
>
I'm done voting
</Button>
)}
<ProgressBar
staticColor
className="voting-board__progress"
Expand All @@ -155,7 +172,16 @@ export default function VotingBoard({ boardId }) {

{closed && (
<div role="status" className="voting-board__error">
<Banner mode="info" withIcon>This session is closed. Voting has ended; the counts below are final.</Banner>
<Banner mode="info" withIcon>This session is closed. Voting has ended. The counts below are final.</Banner>
</div>
)}

{done && !closed && (
<div role="status" className="voting-board__error">
<Banner mode="success" withIcon>
Thanks, your votes are in.{' '}
<Button inline onClick={() => handleDone(false)} disabled={pending}>Keep voting</Button>
</Banner>
</div>
)}

Expand All @@ -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)}
/>
))}
Expand Down
29 changes: 29 additions & 0 deletions webapp/src/components/VotingBoard.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<VotingBoard boardId={uniqueBoardId()} />);
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(<VotingBoard boardId={boardId} />);
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();
});
});
14 changes: 11 additions & 3 deletions webapp/src/data/firestoreBoardStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
onSnapshot,
runTransaction,
setDoc,
updateDoc,
} from 'firebase/firestore';
import { db } from '../firebase';
import { TRACK_PARTICIPANT_COLORS } from '../config.js';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 };
});
}

Expand All @@ -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;
}
2 changes: 1 addition & 1 deletion webapp/src/data/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading