Skip to content

Add word cloud poll type - #242

Open
junkerderprovinz wants to merge 8 commits into
ClaperCo:devfrom
junkerderprovinz:feature/word-cloud-poll-type
Open

Add word cloud poll type#242
junkerderprovinz wants to merge 8 commits into
ClaperCo:devfrom
junkerderprovinz:feature/word-cloud-poll-type

Conversation

@junkerderprovinz

@junkerderprovinz junkerderprovinz commented Aug 16, 2026

Copy link
Copy Markdown

Summary

A minimal take on #79's word cloud request.

What this adds

A "Word Cloud" option next to "Choice" when creating a poll. Attendees type their own word or short phrase instead of picking from choices the moderator set up ahead of time. There's nothing to configure beyond the title, so the options list, "Add" button and "Multiple answers" are hidden for this type in the creation form.

Design choice: reuses poll_opts/poll_votes, no new schema

Polls.submit_word/4 looks for a poll_opt on the poll whose content matches the submitted text case-insensitively (trimmed), increments its vote_count if found or creates a new poll_opt at vote_count 1 if not, then records a PollVote exactly like Polls.vote/4 does. That means "have you already answered this poll" (Polls.get_poll_vote/2), percentages (Polls.set_percentages/1) and the :poll_updated broadcast all work unchanged for both poll types.

The attendee's own screen renders the cloud through PollComponent. The shared/projected presenter display (/e/:code/presenter) has its own separate markup that doesn't use PollComponent, so it needed its own word-cloud branch; both now compute font size through a shared ClaperWeb.Helpers.word_size/3 (extracted from what was a PollComponent-private function, now takes an optional base/range so the bigger presenter screen can size its words differently than the moderator's small panel).

The cloud itself is plain CSS (font-size scaled by each word's share of total votes). Didn't bring in a word-cloud JS library for what's fundamentally a sized-text layout; happy to swap in something fancier if you'd rather have real cloud packing and rotation, and can point me at a preferred library.

New migration adds type (string, default "choice") to polls; every existing poll implicitly stays :choice, no behavior change for polls that already exist.

Defects found and fixed on review

A second pass over my own branch turned up two real bugs, both now fixed with regression tests.

submit-word accepted a submission against whatever interaction was on screen. The handler passed socket.assigns.current_interaction.id straight into Polls.submit_word/4 without checking what that interaction actually was, and the event is client-triggered, so an attendee could fire it regardless of what was being shown. With a choice poll active, upsert_word_poll_opt/2 inserted the submitted text as a brand new PollOpt on that live poll, which is arbitrary text injection into a running presentation. With a form, quiz or embed active, the id belonged to a different table entirely, and because PollOpt.changeset/2 declares no foreign_key_constraint, the insert hit poll_opts_poll_id_fkey, raised Ecto.ConstraintError and took the LiveView process down. The handler heads now match %Polls.Poll{type: :word_cloud, enabled: true} and the event is ignored otherwise, the same shape this module already uses to guard save on chat_enabled and react/unreact on message_reaction_enabled.

The word cloud ignored the "show results" toggle. The cloud rendered as soon as an attendee had submitted, whatever the moderator's setting, so a submitting attendee saw the live cloud even while results were meant to stay hidden. It also rendered directly beneath the "thanks, submitted" message, showing both at once. It is now gated on @show_results alone, which is what the choice branch of the same component already does (bar width zeroed and percentages hidden unless @show_results).

Translations

Ran mix gettext.extract --merge, so default.pot and every locale's default.po pick up the seven new strings. They land untranslated and fall back to the English source until a translation pass, matching how strings arrived in previous feature PRs such as #214. Note that the same run also refreshes #: source references across the catalogues, which had drifted from the current source tree before this branch, so the diff on those files is larger than the seven new strings alone.

Not included

Kept this to the poll mechanics themselves. A few things deliberately left out:

  • profanity or spam filtering on submitted words
  • a moderator-side control to remove a single word from the cloud (deleting the whole poll still works)
  • converting an existing Choice poll (with prior answers) to Word Cloud via the edit form doesn't clear its old poll_opts, so stale choice options would resurface as pre-existing "words"; worth a follow-up if that path matters to you
  • no locking against two attendees submitting the same brand-new word at nearly the same instant, which could in theory create two near-duplicate poll_opts instead of merging into one

Happy to pick any of these up if you'd rather have them in scope here instead of as follow-ups.

Verification

mix test gives 347 tests, 0 failures, run just now against this branch at its current head, in a container matching .github/workflows/elixir.yml (Elixir 1.18.4 / OTP 28, postgres:15, same env). mix format --check-formatted and mix credo diff --from-git-merge-base origin/main are both clean. Earlier commit messages on this branch quoted different counts (336, 339, 341); those were snapshots from different points as tests were added, and 347 is the current, current-head number.

Both fixes above have tests that genuinely fail without them. Applying only the new test files to this branch, with no source fix, reproduces all three symptoms: submit-word against a live choice poll leaves ["some option 1", "some option 2", "injected"] on that poll, submit-word with a form on screen raises Ecto.ConstraintError on poll_opts_poll_id_fkey and terminates the LiveView, and the cloud renders its words while show_results is false. With the fixes in place all of them pass.

End to end against a real running instance: created a word cloud poll as the moderator (confirmed the options UI disappears when switching the type), enabled it, submitted a word as an anonymous attendee, watched it render as the cloud immediately. The size scaling matches word_size/3 on real numbers: with 4 attendees on one word and 1 on another, the 80% word renders at font-size: 41px and the 20% word at font-size: 21px.

On CI

For transparency, since this PR previously quoted test numbers that upstream CI never confirmed: the Elixir CI run on this PR from 2026-08-16 failed at "Check Credo Warnings" and its "Run tests" step was skipped, so the suite never ran upstream at all. 08425e1 addressed those Credo findings. The run queued against the current head is sitting in action_required, GitHub's approval gate for workflow runs on pull requests from forks, so it needs a maintainer to approve it before it will execute. Every number above comes from my own container run, not from upstream CI.

Fixes #79

Adds a "Word Cloud" option next to "Choice" when creating a poll.
Attendees type their own word or short phrase instead of picking from
choices the moderator set up ahead of time -- there's nothing to
configure beyond the title, so the whole options list, the "Add"
button and "Multiple answers" are hidden for this type in the
creation form.

Deliberately reuses the existing poll_opts/poll_votes tables rather
than adding a new schema: Polls.submit_word/4 looks for a poll_opt on
this poll whose content matches the submitted text case-insensitively
(trimmed), increments its vote_count if found or creates a new
poll_opt at vote_count 1 if not, then records a PollVote exactly like
Polls.vote/4 does -- so "have you already answered this poll"
(Polls.get_poll_vote/2), percentages (Polls.set_percentages/1) and the
:poll_updated PubSub broadcast all work unchanged for both poll types,
and both the attendee's own screen and the presenter's shared display
render the live cloud, because they already share PollComponent.

The cloud itself is plain CSS (font-size scaled by each word's share
of total votes) -- no new JS dependency for what's fundamentally a
sized-text layout.

New migration adds `type` (string, default "choice") to polls; every
existing poll implicitly stays type :choice, no behaviour change for
polls that already exist.

Verified end-to-end against a real running instance: created a word
cloud poll as the moderator (confirmed the options UI disappears when
the type is switched), enabled it, submitted a word as an anonymous
attendee, watched it render as the cloud immediately. Added 3 tests
for Polls.submit_word/4 (new word, case-insensitive merge of a
repeated word, blank word rejected) -- full existing test suite still
passes (the one failure in the suite is a pre-existing, unrelated
ImageMagick-availability test).

Fixes ClaperCo#79
Claper.Polls.calculate_percentage/2 returns a binary (via
:erlang.float_to_binary), not a number -- same as every other
opt.percentage use in this file, which all interpolate it directly
into a string ("{opt.percentage}%"). word_size/1 guarded on
is_number(percentage), which never matched, so every word silently
fell through to the 14px fallback regardless of vote count. Add a
is_binary(percentage) clause that parses it first.

Also: mix format (find_matching_poll_opt's where clause needed
wrapping to fit the 98-column default, and poll_component.ex had
inconsistent indentation on a few lines the wrapping ~H sigil
didn't auto-fix).

Verified: submitted the same word from 4 attendees and a different
word from 1, checked the actual rendered inline style attributes --
the 4-vote word (80% share) renders at font-size: 41px, the 1-vote
word (20% share) at 21px, matching the word_size/1 formula.
mix test: 339 tests, 0 failures.
Word-cloud polls only rendered as a cloud in the moderator's own
PollComponent panel; presenter.html.heex had its own separate markup
that showed a plain percentage-bar list instead, so the projected
screen never actually displayed a cloud. Extract word_size/1 into
ClaperWeb.Helpers (now word_size/3 with a base/range so callers can
pick their own scale) and reuse it in both PollComponent and the
presenter template.
`Claper.Polls.submit_word/4` tripped two Credo checks: its body nested
three levels deep (max 2) and its cyclomatic complexity reached 10
(max 9).

Replace the nested `case`/`if` pyramid inside the transaction with a
`with` chain and move the two concerns into private helpers:
`upsert_word_poll_opt/2` for the match-or-create of the poll option and
`create_word_poll_vote/3` (plus a guarded `word_poll_vote_attrs/3`) for
recording the vote. Behaviour is unchanged: the same rollback on either
failure, the same `{:ok, poll}` / `{:error, changeset}` return.
The "submit-word" event is client-triggered, but the handler passed
socket.assigns.current_interaction.id to Polls.submit_word/4 without
checking what that interaction actually was. Any attendee could emit the
event whatever was on screen:

- with a choice poll active, upsert_word_poll_opt/2 inserted the
  submitted text as a brand new PollOpt on that live poll, so arbitrary
  text could be injected into a running presentation;
- with a form, quiz or embed active, the id belonged to another table
  entirely and the insert hit the poll_opts_poll_id_fkey constraint.
  PollOpt.changeset/2 declares no foreign_key_constraint, so that raised
  Ecto.ConstraintError and took the LiveView process down.

Match %Polls.Poll{type: :word_cloud, enabled: true} in the handler heads
and ignore the event otherwise, the way this module already guards
"save" on chat_enabled and "react"/"unreact" on message_reaction_enabled.
The cloud rendered whenever the attendee had submitted, ignoring the
moderator's "show results" toggle, so anyone who submitted saw the live
cloud even while results were meant to stay hidden. It also rendered
directly underneath the "thanks, submitted" message, showing both at
once.

Gate it on @show_results alone, which is how the choice branch of this
component already behaves (bar width zeroed and the percentages hidden
unless @show_results).
Ran `mix gettext.extract --merge`, so default.pot and every locale's
default.po pick up the seven strings this feature adds. They land
untranslated and fall back to the English source until a translation
pass, matching how strings arrived in previous feature PRs.

The run also refreshes `#:` source references across the catalogues,
which had drifted from the current source tree before this branch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Support for Word Cloud poll type?

1 participant