mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 13:56:54 -06:00
* M12: board timer and voting A countdown everyone on a board sees (board.timer, board.timerState), synced as its end time or the time left while paused, and voting sessions (board.vote, board.voteResults): votes per person, on everything, stickies or the selection; clicks vote, your badge takes a vote back; your own votes show while it runs, everyone's with a ranked list once it ends. Timer and vote sync as document properties and ballots as one entry per voter; neither is an undo step. A pill at the top right of the board, new Timer, Pause and Vote icons, labels and strings in every catalog, docs. * M12: board timer and voting on egui 0.37 Id::new is deprecated in egui 0.37: the timer and voting area and its memory ids use Id::unique. * M12: board.vote's start and ballot changes in functions of their own (Sonar)
112 lines
4.0 KiB
Rust
112 lines
4.0 KiB
Rust
//! A board's facilitation state, shared by everyone on it: the countdown timer and the vote.
|
|
//!
|
|
//! The timer keeps when it ends (or, paused, how long is left), never the ticks: each client works
|
|
//! out the time left from its own clock. A vote's ballots are kept per voter, so two people voting
|
|
//! at once change different entries.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{BoardItem, Document, NodeId};
|
|
|
|
/// The longest a timer runs (24 hours, in milliseconds).
|
|
pub const TIMER_MAX_MS: u64 = 24 * 60 * 60 * 1000;
|
|
/// The most votes one person gets in a vote.
|
|
pub const VOTES_MAX: u32 = 99;
|
|
|
|
/// A board's countdown.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "state", rename_all = "camelCase")]
|
|
pub enum BoardTimer {
|
|
/// Counting down to `ends` (milliseconds since the Unix epoch).
|
|
Running { ends: u64 },
|
|
/// Stopped with `left` milliseconds to go.
|
|
Paused { left: u64 },
|
|
}
|
|
|
|
impl BoardTimer {
|
|
/// Milliseconds left at `now` (ms since the Unix epoch); 0 once it has run out.
|
|
pub fn left(&self, now: u64) -> u64 {
|
|
match *self {
|
|
BoardTimer::Running { ends } => ends.saturating_sub(now).min(TIMER_MAX_MS),
|
|
BoardTimer::Paused { left } => left.min(TIMER_MAX_MS),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What a vote is on.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "camelCase")]
|
|
pub enum VoteScope {
|
|
/// Any object on the board.
|
|
#[default]
|
|
All,
|
|
/// Sticky notes only.
|
|
Stickies,
|
|
/// The objects that were selected when it started.
|
|
Only { ids: Vec<NodeId> },
|
|
}
|
|
|
|
impl VoteScope {
|
|
/// The object a vote at layer `leaf` goes to: the nearest layer from it up that the vote is
|
|
/// on (`All`: `whole`, the object a click there picks).
|
|
pub fn target(&self, doc: &Document, leaf: NodeId, whole: NodeId) -> Option<NodeId> {
|
|
if matches!(self, VoteScope::All) {
|
|
return self.takes(doc, whole).then_some(whole);
|
|
}
|
|
std::iter::once(leaf).chain(doc.ancestors(leaf)).find(|id| self.takes(doc, *id))
|
|
}
|
|
|
|
/// Whether object `id` takes votes.
|
|
pub fn takes(&self, doc: &Document, id: NodeId) -> bool {
|
|
let Some(n) = doc.node(id) else { return false };
|
|
if doc.pages.contains(&id) || n.parent.is_none() {
|
|
return false;
|
|
}
|
|
match self {
|
|
VoteScope::All => true,
|
|
VoteScope::Stickies => matches!(n.board, Some(BoardItem::Sticky { .. })),
|
|
VoteScope::Only { ids } => ids.contains(&id),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A vote: how many votes each person has, what they go on, and whether it has ended (then
|
|
/// everyone's votes show). The ballots are [`Document::votes`].
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Vote {
|
|
pub per_person: u32,
|
|
#[serde(default)]
|
|
pub scope: VoteScope,
|
|
#[serde(default)]
|
|
pub ended: bool,
|
|
/// Who started it.
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
pub by: String,
|
|
}
|
|
|
|
/// Every voter's ballot: the objects they put their votes on (once per vote).
|
|
pub type Ballots = BTreeMap<String, Vec<NodeId>>;
|
|
|
|
impl Vote {
|
|
/// The votes of `ballot` that count: on objects that are still there, up to the cap.
|
|
pub fn counted<'a>(&self, doc: &'a Document, ballot: &'a [NodeId]) -> impl Iterator<Item = NodeId> + 'a {
|
|
ballot.iter().copied().filter(|id| doc.node(*id).is_some()).take(self.per_person.min(VOTES_MAX) as usize)
|
|
}
|
|
|
|
/// The votes each object got, most first (ties in layer id order).
|
|
pub fn tally(&self, doc: &Document, ballots: &Ballots) -> Vec<(NodeId, u32)> {
|
|
let mut counts: BTreeMap<NodeId, u32> = BTreeMap::new();
|
|
for ballot in ballots.values() {
|
|
for id in self.counted(doc, ballot) {
|
|
*counts.entry(id).or_default() += 1;
|
|
}
|
|
}
|
|
let mut out: Vec<(NodeId, u32)> = counts.into_iter().collect();
|
|
out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
|
out
|
|
}
|
|
}
|