Skip to content

Commit 68252f5

Browse files
authored
Track card and voting state on socket (#7)
1 parent 8289f53 commit 68252f5

File tree

6 files changed

+184
-39
lines changed

6 files changed

+184
-39
lines changed

assets/js/socket.js

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,91 @@ const presence = new Presence(channel)
99

1010
presence.onSync(() => updateUsers(presence))
1111

12+
let driving = false
13+
1214
if (window.pointingParty.username) {
13-
channel.join ()
15+
channel.join()
1416
.receive('ok', resp => { console.log('Joined successfully', resp) })
1517
.receive('error', resp => { console.log('Unable to join', resp) })
1618
}
1719

18-
const calculateButton = document.querySelector('.calculate-points')
19-
calculateButton.addEventListener('click', event => {
20-
const storyPoints = document.querySelector('.story-points')
21-
channel.push('user_estimated', { points: storyPoints.value })
20+
const renderTemplate = function(parent, template) {
21+
while (parent.firstChild) {
22+
parent.removeChild(parent.firstChild)
23+
}
24+
25+
const fragment = document.createRange().createContextualFragment(template)
26+
parent.appendChild(fragment)
27+
}
28+
29+
const startButton = document.querySelector('.start-button')
30+
startButton.addEventListener('click', e => {
31+
driving = true;
32+
channel.push('start_pointing', {})
33+
})
34+
35+
const cardContainer = document.querySelector('.card-container')
36+
channel.on('new_card', state => {
37+
const template =
38+
'<div class="card text-left">' +
39+
' <div class="card-header">' +
40+
' <h2>' + state.card.title + '</h2>' +
41+
' </div>' +
42+
' <div class="card-body">' +
43+
' <p class="card-text">' + state.card.description + '</p>' +
44+
' <div class="form-group text-left points-container">' +
45+
' <div class="form-row align-items-center">' +
46+
' <div class="col-2">' +
47+
' <label for="story-points">Story Points</label>' +
48+
' <select class="form-control story-points" id="story-points">' +
49+
' <option>1</option>' +
50+
' <option>2</option>' +
51+
' <option>3</option>' +
52+
' <option>5</option>' +
53+
' </select>' +
54+
' </div>' +
55+
' </div>' +
56+
' <a href="#" class="btn btn-primary calculate-points">Vote!</a>' +
57+
' </div>' +
58+
' </div>' +
59+
'</div>'
60+
61+
renderTemplate(cardContainer, template)
62+
63+
document
64+
.querySelector('.calculate-points')
65+
.addEventListener('click', event => {
66+
const storyPoints = document.querySelector('.story-points')
67+
channel.push('user_estimated', { points: storyPoints.value })
68+
})
69+
})
70+
71+
const renderVotingResults = function(template) {
72+
const pointContainer = document.querySelector('.points-container')
73+
renderTemplate(pointContainer, template)
74+
75+
document
76+
.querySelector('.next-card')
77+
.addEventListener('click', e => {
78+
channel.push('finalized_points', { points: e.target.value })
79+
})
80+
}
81+
82+
channel.on('winner', state => {
83+
const template =
84+
'<p>' + state.points + ' Points </p>' +
85+
'<button ' + (driving ? '' : 'disabled=true') + ' class="btn btn-primary next-card" value=' + state.points + '>Next Card</a>'
86+
87+
renderVotingResults(template)
88+
})
89+
90+
channel.on('tie', state => {
91+
const template =
92+
'<p class="card-text"> TIE! </p>' +
93+
'<button ' + (driving ? '' : 'disabled=true') + ' class="btn btn-primary next-card" value=' + state.points[0] + '>Pick ' + state.points[0] + ' </a>' +
94+
'<button ' + (driving ? '' : 'disabled=true') + ' class="btn btn-primary next-card" value=' + state.points[1] + '>Pick ' + state.points[1] + ' </a>'
95+
96+
renderVotingResults(template)
2297
})
2398

2499
export default socket

lib/pointing_party/card.ex

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,7 @@ defmodule PointingParty.Card do
2323
|> validate_inclusion(:points, @pointing_scale)
2424
end
2525

26-
def first, do: List.first(cards())
27-
28-
defp cards do
26+
def cards do
2927
:pointing_party
3028
|> Application.get_env(:cards)
3129
|> Enum.map(&struct(__MODULE__, &1))

lib/pointing_party_web/channels/room_channel.ex

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
defmodule PointingPartyWeb.RoomChannel do
22
use PointingPartyWeb, :channel
33

4+
alias PointingParty.Card
45
alias PointingPartyWeb.Presence
56

67
def join("room:lobby", _payload, socket) do
@@ -19,6 +20,103 @@ defmodule PointingPartyWeb.RoomChannel do
1920
def handle_in("user_estimated", %{"points" => points}, socket) do
2021
Presence.update(socket, socket.assigns.username, &(Map.put(&1, :points, points)))
2122

23+
if everyone_voted?(socket) do
24+
finalize_voting(socket)
25+
end
26+
2227
{:noreply, socket}
2328
end
29+
30+
def handle_in("finalized_points", %{"points" => points}, socket) do
31+
updated_socket = save_vote_next_card(points, socket)
32+
broadcast!(updated_socket, "new_card", %{card: current_card(updated_socket)})
33+
{:reply, :ok, updated_socket}
34+
end
35+
36+
def handle_in("start_pointing", _params, socket) do
37+
updated_socket = initialize_state(socket)
38+
broadcast!(updated_socket, "new_card", %{card: current_card(updated_socket)})
39+
{:reply, :ok, updated_socket}
40+
end
41+
42+
defp current_card(socket) do
43+
socket.assigns
44+
|> Map.get(:current)
45+
|> Map.from_struct()
46+
|> Map.drop([:__meta__])
47+
end
48+
49+
defp everyone_voted?(socket) do
50+
socket
51+
|> Presence.list()
52+
|> Enum.map(fn {_username, %{metas: [metas]}} -> Map.get(metas, :points) end)
53+
|> Enum.all?(&(not is_nil(&1)))
54+
end
55+
56+
defp finalize_voting(socket) do
57+
current_users = Presence.list(socket)
58+
59+
{event, points} =
60+
case winning_vote(current_users) do
61+
top_two when is_list(top_two) -> {"tie", top_two}
62+
winner -> {"winner", winner}
63+
end
64+
65+
broadcast!(socket, event, %{points: points})
66+
end
67+
68+
defp initialize_state(%{assigns: %{cards: _cards}} = socket), do: socket
69+
defp initialize_state(socket) do
70+
[first | cards] = Card.cards()
71+
72+
socket
73+
|> assign(:points, Card.points_range())
74+
|> assign(:unvoted, cards)
75+
|> assign(:current, first)
76+
end
77+
78+
defp save_vote_next_card(points, socket) do
79+
latest_card =
80+
socket.assigns
81+
|> Map.get(:current)
82+
|> Map.put(:points, points)
83+
84+
{next, remaining} =
85+
socket.assigns
86+
|> Map.get(:unvoted)
87+
|> List.pop_at(0)
88+
89+
socket
90+
|> assign(:unvoted, remaining)
91+
|> assign(:current, next)
92+
|> assign(:voted, [latest_card | socket.assigns[:voted]])
93+
end
94+
95+
defp winning_vote(users) do
96+
votes = Enum.map(users, fn {_username, %{metas: [%{points: points}]}} -> points end)
97+
calculated_votes = Enum.reduce(votes, %{}, fn vote, acc ->
98+
acc
99+
|> Map.get_and_update(vote, &({&1, (&1 || 0) + 1}))
100+
|> elem(1)
101+
end)
102+
103+
total_votes = length(votes)
104+
105+
majority = Enum.reduce_while(calculated_votes, nil, fn {point, vote_count}, _acc ->
106+
if vote_count == total_votes or rem(vote_count, total_votes) > 5 do
107+
{:halt, point}
108+
else
109+
{:cont, nil}
110+
end
111+
end)
112+
113+
if is_nil(majority) do
114+
calculated_votes
115+
|> Enum.sort_by(&elem(&1, 1))
116+
|> Enum.take(2)
117+
|> Enum.map(&elem(&1, 0))
118+
else
119+
majority
120+
end
121+
end
24122
end
Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
defmodule PointingPartyWeb.CardController do
22
use PointingPartyWeb, :controller
3-
alias PointingParty.Card
43

54
def index(conn, _params) do
6-
# temporary, just to get something on the page for now
7-
card = Card.first()
8-
points = Card.points_range()
9-
render(conn, "index.html", card: card, points: points)
5+
render(conn, "index.html")
106
end
117
end

lib/pointing_party_web/templates/card/index.html.eex

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,7 @@
11
<div class="row">
2-
<div class="col-10">
3-
<div class="card text-left">
4-
<div class="card-header">
5-
<h2><%= @card.title %></h2>
6-
</div>
7-
<div class="card-body">
8-
<p class="card-text"><%= @card.description %></p>
9-
<div class="form-group text-left">
10-
<div class="form-row align-items-center">
11-
<div class="col-2">
12-
<label for="story-points">Story Points</label>
13-
<select class="form-control story-points" id="story-points">
14-
<%= Enum.map(@points, fn point -> %>
15-
<option><%= point %></option>
16-
<% end) %>
17-
</select>
18-
</div>
19-
</div>
20-
</div>
21-
<a href="#" class="btn btn-primary calculate-points">Calculate Points</a>
22-
</div>
23-
24-
<div class="card-footer text-muted">
25-
created at: <%= @card.inserted_at %>
26-
</div>
2+
<div class="card-container col-10">
3+
<div class="col-md-4 text-center">
4+
<button class="start-button btn btn-primary">Start the Party!</button>
275
</div>
286
</div>
297

priv/static/js/app.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)