Compare commits

..

4 Commits

Author SHA1 Message Date
haruki 20375636b4 Update README.md 2026-07-04 19:43:17 +09:00
haruki e10a1cf717 add docker image 2026-07-04 19:40:19 +09:00
haruki 8d36441a17 Update README.md 2026-07-04 19:30:20 +09:00
haruki ba6a0b2c17 oneshot 2026-07-04 19:27:17 +09:00
38 changed files with 3267 additions and 259 deletions
+46
View File
@@ -0,0 +1,46 @@
# This file excludes paths from the Docker build context.
#
# By default, Docker's build context includes all files (and folders) in the
# current directory. Even if a file isn't copied into the container it is still sent to
# the Docker daemon.
#
# There are multiple reasons to exclude files from the build context:
#
# 1. Prevent nested folders from being copied into the container (ex: exclude
# /assets/node_modules when copying /assets)
# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
# 3. Avoid sending files containing sensitive information
#
# More information on using .dockerignore is available here:
# https://docs.docker.com/engine/reference/builder/#dockerignore-file
.dockerignore
# Ignore git, but keep git HEAD and refs to access current commit hash if needed:
#
# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
# d0b8727759e1e0e7aa3d41707d12376e373d5ecc
.git
!.git/HEAD
!.git/refs
# Common development/test artifacts
/cover/
/doc/
/test/
/tmp/
.elixir_ls
# Mix artifacts
/_build/
/deps/
*.ez
# Generated on crash by the VM
erl_crash.dump
# Static artifacts - These should be fetched and built inside the Docker image
# https://phoenix.hexdocs.pm/Mix.Tasks.Phx.Gen.Release.html#module-docker
/assets/node_modules/
/priv/static/assets/
/priv/static/cache_manifest.json
+101
View File
@@ -0,0 +1,101 @@
# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
# instead of Alpine to avoid DNS resolution issues in production.
#
# https://hub.docker.com/r/hexpm/elixir/tags?name=ubuntu
# https://hub.docker.com/_/ubuntu/tags
#
# This file is based on these images:
#
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
# - https://hub.docker.com/_/debian/tags?name=trixie-20260610-slim - for the release image
# - https://pkgs.org/ - resource for finding needed packages
# - Ex: docker.io/hexpm/elixir:1.18.4-erlang-28.5.0.2-debian-trixie-20260610-slim
#
ARG ELIXIR_VERSION=1.18.4
ARG OTP_VERSION=28.5.0.2
ARG DEBIAN_VERSION=trixie-20260610-slim
ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}"
FROM ${BUILDER_IMAGE} AS builder
# install build dependencies
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential git \
&& rm -rf /var/lib/apt/lists/*
# prepare build dir
WORKDIR /app
# install hex + rebar
RUN mix local.hex --force \
&& mix local.rebar --force
# set build ENV
ENV MIX_ENV="prod"
# install mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config
# copy compile-time config files before we compile dependencies
# to ensure any relevant config change will trigger the dependencies
# to be re-compiled.
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile
RUN mix assets.setup
COPY priv priv
COPY lib lib
# Compile the release
RUN mix compile
COPY assets assets
# compile assets
RUN mix assets.deploy
# Changes to config/runtime.exs don't require recompiling the code
COPY config/runtime.exs config/
COPY rel rel
RUN mix release
# start a new build stage so that the final image will only contain
# the compiled release and other runtime necessities
FROM ${RUNNER_IMAGE} AS final
RUN apt-get update \
&& apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen \
&& locale-gen
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
WORKDIR "/app"
RUN chown nobody /app
# set runner ENV
ENV MIX_ENV="prod"
# Only copy the final release from the build stage
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/rsh26pool ./
USER nobody
# If using an environment that doesn't automatically reap zombie processes, it is
# advised to add an init process such as tini via `apt-get install`
# above and adding an entrypoint. See https://github.com/krallin/tini for details
# ENTRYPOINT ["/tini", "--"]
CMD ["/app/bin/server"]
+142 -12
View File
@@ -1,18 +1,148 @@
# Rsh26pool # みんなで投票(Rsh26pool
To start your Phoenix server: 投票ルームを作成・参加し、アイデアを投稿して気に入ったものに投票できる、リアルタイムな投票 Web アプリケーションです。班分け(グルーピング)モードや投票の自動終了にも対応しています。
* Run `mix setup` to install and setup dependencies Elixir / Phoenix (LiveView) と PostgreSQL で実装されており、UI はすべて日本語、スタイリングは Tailwind CSSdaisyUI)を使用しています。
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. ## 主な機能
Ready to run in production? Please [check our deployment guides](https://phoenix.hexdocs.pm/deployment.html). ### 基本機能
## Learn more - **ルーム作成**:ルーム名や一人あたりの投票数などを設定して投票ルームを作成します。
- **ルーム参加**:ルーム番号と名前を入力して既存のルームに参加します。
- **投票**:アイデアを投稿し、気に入った選択肢に投票できます。
- 選択肢の表示順は参加者ごとにシャッフルされます。
- 投票中は各選択肢の得票数は表示されません。
- **管理**:ルームのオーナーは投票の終了やルーム名の変更ができ、得票数付きの結果をリアルタイムで確認できます。
- **トークン認証**:ランダムなトークンを発行してデータベースに保存し、Cookie(セッション)に保持することで各参加者を識別します。ログアウトも可能です。
* Official website: https://www.phoenixframework.org/ ### 追加機能
* Guides: https://phoenix.hexdocs.pm/overview.html
* Docs: https://phoenix.hexdocs.pm - **グルーピングモード**:投票アプリが班分けアプリに変化します。各参加者が意見を投稿し、十分な票を集めた人がリーダーになります。残りの参加者は、投票内容(参加したいグループ)をもとに **ハンガリアン法(Hungarian algorithm** で各グループへ均等に割り当てられます。
* Forum: https://elixirforum.com/c/phoenix-forum - **自動終了**:ルーム作成時に「投票終了日時」を設定すると、その日時(日本時間)に投票が自動的に終了します。
* Source: https://github.com/phoenixframework/phoenix
## ページ一覧
| パス | 内容 |
| --- | --- |
| `/` | ホーム。ルームの作成/参加を選択します。 |
| `/create` | ルーム作成ページ。作成後、オーナーは `/manage/{ルーム番号}` にリダイレクトされます。 |
| `/enter` | ルーム番号と名前を入力して参加するページ。 |
| `/vote/{ルーム番号}` | 投票ページ(LiveView)。 |
| `/manage/{ルーム番号}` | 管理ページ(LiveView、オーナー専用)。 |
## 技術スタック
- **言語 / フレームワーク**Elixir, Phoenix 1.8, Phoenix LiveView
- **データベース**PostgreSQLEcto
- **フロントエンド**Tailwind CSS v4 + daisyUI, esbuild
## 必要要件
- Elixir 1.15 以上(Erlang/OTP 26 以上)
- PostgreSQL
## セットアップ
### 1. データベース接続の設定
データベース接続は環境変数 `DATABASE_URL` から読み込みます。プロジェクトルートの `.env` に設定してください。
```
DATABASE_URL=postgresql://ユーザー名:パスワード@localhost:5432/rsh26pool_dev
```
> `DATABASE_URL` が未設定の場合は、開発環境ではローカルの `postgres/postgres@localhost` にフォールバックします。
### 2. 依存関係のインストールと DB 初期化
`.env` を読み込んでから `mix setup`(依存関係の取得・DB作成・マイグレーション・アセットのビルド)を実行します。
```bash
set -a; source .env; set +a
mix setup
```
## 起動方法
環境変数を読み込み、Phoenix サーバーを起動します。
```bash
set -a; source .env; set +a
mix phx.server
```
IEx(対話シェル)付きで起動する場合:
```bash
set -a; source .env; set +a
iex -S mix phx.server
```
起動後、ブラウザで [`http://localhost:4000`](http://localhost:4000) にアクセスしてください。
## 使い方
1. ホーム(`/`)で「ルームを作成」を選び、ルーム名や一人あたりの投票数を設定します。必要に応じて「グルーピングモード」や「投票終了日時」を指定します。
2. 作成後に表示される **ルーム番号** を参加者に伝えます。
3. 参加者はホームの「ルームに参加」からルーム番号と名前を入力して参加し、`/vote/{ルーム番号}` でアイデアの投稿と投票を行います。
4. オーナーは `/manage/{ルーム番号}` で結果を確認し、投票を終了できます。グルーピングモードでは、終了時にグループ分けの結果が表示されます。
## テスト
```bash
set -a; source .env; set +a
mix test
```
コミット前の一括チェック(コンパイル・フォーマット・テスト):
```bash
set -a; source .env; set +a
mix precommit
```
> テストは `DATABASE_URL` の接続先サーバー上に、専用の `..._test` データベースを自動的に作成して実行します。
## Docker でのデプロイ
Docker を使って本番用リリースをビルド・起動できます。データベース接続 URL は、コンテナの **起動時** に環境変数 `DATABASE_URL` から読み込まれます(イメージには埋め込まれません)。
### 1. イメージのビルド
```bash
docker build -t rsh26pool:latest .
```
### 2. マイグレーションの実行
```bash
docker run --rm \
-e DATABASE_URL="ecto://ユーザー名:パスワード@DBホスト:5432/rsh26pool_prod" \
-e SECRET_KEY_BASE="$(mix phx.gen.secret)" \
rsh26pool:latest /app/bin/migrate
```
### 3. サーバーの起動
```bash
docker run -p 4000:4000 \
-e DATABASE_URL="ecto://ユーザー名:パスワード@DBホスト:5432/rsh26pool_prod" \
-e SECRET_KEY_BASE="生成したシークレット" \
-e PHX_HOST="example.com" \
rsh26pool:latest
```
起動後、ブラウザで [`http://localhost:4000`](http://localhost:4000) にアクセスしてください。
### 環境変数
| 変数 | 必須 | 説明 |
| --- | --- | --- |
| `DATABASE_URL` | 必須 | データベース接続 URL(例:`ecto://ユーザー名:パスワード@DBホスト:5432/rsh26pool_prod`)。 |
| `SECRET_KEY_BASE` | 必須 | Cookie の署名・暗号化に使用。`mix phx.gen.secret` で生成できます。 |
| `PHX_HOST` | 推奨 | 公開ホスト名。未設定時は `example.com`。 |
| `PORT` | 任意 | 待ち受けポート。既定は `4000`。 |
| `POOL_SIZE` | 任意 | DB コネクションプールのサイズ。既定は `10`。 |
> 本番設定(`config/prod.exs`)では `force_ssl`HSTS)が有効です。`localhost` 以外のホストでは `https` にリダイレクトされるため、TLS を終端して `x-forwarded-proto` ヘッダーを付与するリバースプロキシの背後で運用してください。
+14 -5
View File
@@ -1,15 +1,24 @@
import Config import Config
# Configure your database # Configure your database.
#
# The connection is read from the DATABASE_URL environment variable (see .env)
# when present, otherwise it falls back to a local Postgres instance.
config :rsh26pool, Rsh26pool.Repo, config :rsh26pool, Rsh26pool.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "rsh26pool_dev",
stacktrace: true, stacktrace: true,
show_sensitive_data_on_connection_error: true, show_sensitive_data_on_connection_error: true,
pool_size: 10 pool_size: 10
if url = System.get_env("DATABASE_URL") do
config :rsh26pool, Rsh26pool.Repo, url: url
else
config :rsh26pool, Rsh26pool.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "rsh26pool_dev"
end
# For development, we disable any cache and enable # For development, we disable any cache and enable
# debugging and code reloading. # debugging and code reloading.
# #
+35 -2
View File
@@ -5,13 +5,46 @@ import Config
# The MIX_TEST_PARTITION environment variable can be used # The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment. # to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information. # Run `mix help test` for more information.
config :rsh26pool, Rsh26pool.Repo, #
# When DATABASE_URL is set (see .env) we reuse its server but connect to a
# dedicated "<db>_test" database so tests never touch development data.
partition = System.get_env("MIX_TEST_PARTITION")
if url = System.get_env("DATABASE_URL") do
uri = URI.parse(url)
{username, password} =
case uri.userinfo do
nil ->
{"postgres", "postgres"}
info ->
case String.split(info, ":", parts: 2) do
[u, p] -> {u, p}
[u] -> {u, nil}
end
end
base = uri.path |> to_string() |> String.trim_leading("/")
base = if base == "", do: "rsh26pool", else: base
config :rsh26pool, Rsh26pool.Repo,
username: username,
password: password,
hostname: uri.host || "localhost",
port: uri.port || 5432,
database: "#{base}_test#{partition}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2
else
config :rsh26pool, Rsh26pool.Repo,
username: "postgres", username: "postgres",
password: "postgres", password: "postgres",
hostname: "localhost", hostname: "localhost",
database: "rsh26pool_test#{System.get_env("MIX_TEST_PARTITION")}", database: "rsh26pool_test#{partition}",
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2 pool_size: System.schedulers_online() * 2
end
# We don't run a server during test. If one is required, # We don't run a server during test. If one is required,
# you can enable the server option below. # you can enable the server option below.
+2 -2
View File
@@ -12,8 +12,8 @@ defmodule Rsh26pool.Application do
Rsh26pool.Repo, Rsh26pool.Repo,
{DNSCluster, query: Application.get_env(:rsh26pool, :dns_cluster_query) || :ignore}, {DNSCluster, query: Application.get_env(:rsh26pool, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Rsh26pool.PubSub}, {Phoenix.PubSub, name: Rsh26pool.PubSub},
# Start a worker by calling: Rsh26pool.Worker.start_link(arg) # Periodically closes rooms that have reached their auto-end time.
# {Rsh26pool.Worker, arg}, Rsh26pool.RoomCloser,
# Start to serve requests, typically the last entry # Start to serve requests, typically the last entry
Rsh26poolWeb.Endpoint Rsh26poolWeb.Endpoint
] ]
+52
View File
@@ -0,0 +1,52 @@
defmodule Rsh26pool.JST do
@moduledoc """
Helpers for converting between UTC (how timestamps are stored) and
Japan Standard Time (JST, UTC+9) which is what the UI shows and accepts.
A full time zone database is not configured for this demo, and JST has no
daylight saving time, so a fixed +9h offset is sufficient and dependency free.
"""
@offset_seconds 9 * 3600
@doc """
Parses a `datetime-local` form value (e.g. `"2026-07-04T15:30"`), interpreting
it as wall-clock JST, and returns the corresponding UTC `DateTime`.
"""
@spec parse_local(String.t()) :: {:ok, DateTime.t()} | :error
def parse_local(value) when is_binary(value) do
with {:ok, naive} <- NaiveDateTime.from_iso8601(ensure_seconds(value)) do
utc =
naive
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.add(-@offset_seconds, :second)
|> DateTime.truncate(:second)
{:ok, utc}
else
_ -> :error
end
end
def parse_local(_), do: :error
@doc "Converts a stored UTC `DateTime` to a JST `DateTime` for display."
@spec to_local(DateTime.t()) :: DateTime.t()
def to_local(%DateTime{} = dt), do: DateTime.add(dt, @offset_seconds, :second)
@doc "Formats a stored UTC `DateTime` as a human friendly JST string."
@spec format(DateTime.t() | nil) :: String.t()
def format(nil), do: ""
def format(%DateTime{} = dt) do
l = to_local(dt)
"~4..0B/~2..0B/~2..0B ~2..0B:~2..0B"
|> :io_lib.format([l.year, l.month, l.day, l.hour, l.minute])
|> to_string()
end
defp ensure_seconds(value) do
if Regex.match?(~r/T\d{2}:\d{2}$/, value), do: value <> ":00", else: value
end
end
+30
View File
@@ -0,0 +1,30 @@
defmodule Rsh26pool.Release do
@moduledoc """
Used for executing DB release tasks when run in production without Mix
installed.
"""
@app :rsh26pool
def migrate do
load_app()
for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
def rollback(repo, version) do
load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end
defp load_app do
# Many platforms require SSL when connecting to the database
Application.ensure_all_started(:ssl)
Application.ensure_loaded(@app)
end
end
+45
View File
@@ -0,0 +1,45 @@
defmodule Rsh26pool.RoomCloser do
@moduledoc """
Periodically closes rooms whose `closes_at` deadline has passed (the "auto
end" feature). Closing broadcasts to any connected LiveViews so open pages
transition to the results/grouping view without a manual refresh.
"""
use GenServer
require Logger
alias Rsh26pool.Voting
@interval :timer.seconds(10)
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(_opts) do
schedule()
{:ok, %{}}
end
@impl true
def handle_info(:sweep, state) do
sweep()
schedule()
{:noreply, state}
end
defp sweep do
for room <- Voting.list_rooms_to_auto_close() do
case Voting.close_room(room) do
{:ok, _} ->
:ok
other ->
Logger.warning("RoomCloser failed to close room #{room.id}: #{inspect(other)}")
end
end
end
defp schedule, do: Process.send_after(self(), :sweep, @interval)
end
+523
View File
@@ -0,0 +1,523 @@
defmodule Rsh26pool.Voting do
@moduledoc """
The Voting context: rooms, participants, ideas/opinions, votes, results and
(in grouping mode) team assignment via the Hungarian algorithm.
Timestamps are stored in UTC. Real-time updates are published over
`Phoenix.PubSub`; interested LiveViews call `subscribe/1` and handle
`{:voting, event}` messages.
"""
import Ecto.Query, warn: false
alias Rsh26pool.Repo
alias Rsh26pool.Voting.{Hungarian, Idea, Participant, Room, Vote}
@pubsub Rsh26pool.PubSub
## Real-time -------------------------------------------------------------
@doc "Subscribe the calling process to a room's update stream."
def subscribe(room_id) do
Phoenix.PubSub.subscribe(@pubsub, topic(room_id))
end
defp broadcast(room_id, event) do
Phoenix.PubSub.broadcast(@pubsub, topic(room_id), {:voting, event})
end
defp topic(room_id), do: "room:#{room_id}"
## Rooms ----------------------------------------------------------------
@doc "Builds a creation changeset for the room settings form."
def change_room_creation(%Room{} = room \\ %Room{}, attrs \\ %{}) do
Room.create_changeset(room, attrs)
end
@doc "Builds a settings changeset for the manage page rename form."
def change_room_settings(%Room{} = room, attrs \\ %{}) do
Room.settings_changeset(room, attrs)
end
@doc """
Creates a room from the settings form and its owner participant.
Returns `{:ok, %{room: room, owner: owner}}` or `{:error, changeset}`.
"""
def create_room(attrs) do
changeset = Room.create_changeset(%Room{}, attrs)
if changeset.valid? do
changeset = Ecto.Changeset.put_change(changeset, :room_number, unique_room_number())
Repo.transaction(fn ->
room = Repo.insert!(changeset)
owner =
Repo.insert!(%Participant{
room_id: room.id,
role: "owner",
name: "オーナー",
token: generate_token()
})
%{room: room, owner: owner}
end)
else
{:error, %{changeset | action: :insert}}
end
end
def get_room_by_number(number) when is_binary(number) do
Repo.get_by(Room, room_number: number)
end
def get_room_by_number(_), do: nil
def get_room!(id), do: Repo.get!(Room, id)
def reload_room(%Room{id: id}), do: Repo.get(Room, id)
@doc "Updates owner-editable settings (currently the room name)."
def update_room_settings(%Room{} = room, attrs) do
room
|> Room.settings_changeset(attrs)
|> Repo.update()
|> case do
{:ok, room} = ok ->
broadcast(room.id, :room_updated)
ok
other ->
other
end
end
@doc """
The status a room should be treated as *right now*, accounting for a passed
`closes_at` deadline even if the background closer has not run yet.
"""
def effective_status(%Room{status: "closed"}), do: "closed"
def effective_status(%Room{closes_at: nil}), do: "open"
def effective_status(%Room{closes_at: closes_at}) do
if DateTime.compare(closes_at, DateTime.utc_now()) != :gt, do: "closed", else: "open"
end
def room_open?(%Room{} = room), do: effective_status(room) == "open"
@doc """
Closes a room: marks it closed and, in grouping mode, computes the team
assignments (idempotent — safe to call more than once).
"""
def close_room(%Room{} = room) do
room = Repo.get!(Room, room.id)
if room.status == "closed" do
{:ok, room}
else
{:ok, closed} =
room
|> Ecto.Changeset.change(status: "closed")
|> Repo.update()
closed = if closed.grouping_mode, do: compute_grouping(closed), else: closed
broadcast(closed.id, :room_updated)
{:ok, closed}
end
end
@doc """
Ensures a room past its deadline is actually closed. Returns the (possibly
updated) room. Used lazily by pages so viewers never see a stale open room.
"""
def ensure_closed(%Room{} = room) do
if room.status == "open" and effective_status(room) == "closed" do
{:ok, closed} = close_room(room)
closed
else
room
end
end
@doc "Lists rooms that are open but past their auto-close deadline."
def list_rooms_to_auto_close do
now = DateTime.utc_now()
Repo.all(
from r in Room,
where: r.status == "open" and not is_nil(r.closes_at) and r.closes_at <= ^now
)
end
## Participants ---------------------------------------------------------
@doc "Changeset for the join form."
def change_participant(%Participant{} = participant \\ %Participant{}, attrs \\ %{}) do
Participant.changeset(participant, attrs)
end
@doc """
Joins a room as a member, generating an auth token.
Returns `{:ok, participant}` or `{:error, changeset}`.
"""
def join_room(%Room{} = room, attrs) do
%Participant{room_id: room.id, role: "member", token: generate_token()}
|> Participant.changeset(attrs)
|> Repo.insert()
end
def get_participant_by_token(nil), do: nil
def get_participant_by_token(token) when is_binary(token) do
Repo.one(from p in Participant, where: p.token == ^token, preload: [:room])
end
def list_participants(%Room{} = room) do
Repo.all(from p in Participant, where: p.room_id == ^room.id, order_by: [asc: p.id])
end
def count_participants(%Room{} = room) do
Repo.aggregate(from(p in Participant, where: p.room_id == ^room.id), :count)
end
## Ideas ----------------------------------------------------------------
def change_idea(%Idea{} = idea \\ %Idea{}, attrs \\ %{}) do
Idea.changeset(idea, attrs)
end
@doc """
Posts an idea/opinion. In grouping mode each participant has a single opinion,
so an existing one is updated instead of inserting a second.
"""
def create_idea(%Room{} = room, %Participant{} = participant, attrs) do
cond do
not room_open?(room) ->
{:error, :closed}
room.grouping_mode ->
case get_participant_idea(room, participant) do
nil -> do_insert_idea(room, participant, attrs)
existing -> do_update_idea(room, existing, attrs)
end
true ->
do_insert_idea(room, participant, attrs)
end
end
defp do_insert_idea(room, participant, attrs) do
%Idea{room_id: room.id, participant_id: participant.id}
|> Idea.changeset(attrs)
|> Repo.insert()
|> tap_broadcast(room, :ideas_changed)
end
defp do_update_idea(room, idea, attrs) do
idea
|> Idea.changeset(attrs)
|> Repo.update()
|> tap_broadcast(room, :ideas_changed)
end
def get_participant_idea(%Room{} = room, %Participant{} = participant) do
Repo.one(
from i in Idea,
where: i.room_id == ^room.id and i.participant_id == ^participant.id,
order_by: [asc: i.id],
limit: 1
)
end
def list_ideas(%Room{} = room) do
Repo.all(
from i in Idea,
where: i.room_id == ^room.id,
order_by: [asc: i.id],
preload: [:participant]
)
end
@doc """
Ideas ordered by a per-viewer deterministic shuffle. Order is stable for a
given `seed` (so it does not jump around on live updates) but differs between
viewers, satisfying the "shuffled choices" requirement.
"""
def shuffled_ideas(%Room{} = room, seed) do
room
|> list_ideas()
|> Enum.sort_by(fn idea -> :erlang.phash2({seed, idea.id}) end)
end
## Votes ----------------------------------------------------------------
@doc """
Casts a vote for `idea_id` by `participant`, enforcing the room's per-user
vote limit, one-vote-per-idea, and that the room is still open.
"""
def vote(%Participant{} = participant, idea_id) do
room = Repo.get!(Room, participant.room_id)
idea = Repo.get_by(Idea, id: idea_id, room_id: room.id)
cond do
not room_open?(room) -> {:error, :closed}
is_nil(idea) -> {:error, :not_found}
voted?(participant, idea.id) -> {:error, :already_voted}
votes_used(participant) >= room.votes_per_user -> {:error, :limit_reached}
true -> insert_vote(room, participant, idea)
end
end
defp insert_vote(room, participant, idea) do
%Vote{}
|> Vote.changeset(%{idea_id: idea.id, participant_id: participant.id})
|> Repo.insert()
|> case do
{:ok, vote} ->
broadcast(room.id, :votes_changed)
{:ok, vote}
{:error, changeset} ->
{:error, changeset}
end
end
@doc "Removes a participant's vote for an idea, if the room is still open."
def unvote(%Participant{} = participant, idea_id) do
room = Repo.get!(Room, participant.room_id)
cond do
not room_open?(room) ->
{:error, :closed}
true ->
case Repo.get_by(Vote, idea_id: idea_id, participant_id: participant.id) do
nil ->
{:error, :not_found}
vote ->
{:ok, _} = Repo.delete(vote)
broadcast(room.id, :votes_changed)
:ok
end
end
end
def voted?(%Participant{} = participant, idea_id) do
Repo.exists?(
from v in Vote, where: v.participant_id == ^participant.id and v.idea_id == ^idea_id
)
end
def votes_used(%Participant{} = participant) do
Repo.aggregate(from(v in Vote, where: v.participant_id == ^participant.id), :count)
end
def votes_remaining(%Room{} = room, %Participant{} = participant) do
max(room.votes_per_user - votes_used(participant), 0)
end
@doc "Set of idea ids the participant has voted for (for UI highlighting)."
def voted_idea_ids(%Participant{} = participant) do
Repo.all(from v in Vote, where: v.participant_id == ^participant.id, select: v.idea_id)
|> MapSet.new()
end
## Results --------------------------------------------------------------
@doc "Map of `idea_id => vote_count` for a room."
def vote_counts(%Room{} = room) do
Repo.all(
from v in Vote,
join: i in Idea,
on: i.id == v.idea_id,
where: i.room_id == ^room.id,
group_by: v.idea_id,
select: {v.idea_id, count(v.id)}
)
|> Map.new()
end
@doc "Ideas with their vote counts, highest first (for the manage page)."
def results(%Room{} = room) do
counts = vote_counts(room)
room
|> list_ideas()
|> Enum.map(fn idea -> %{idea: idea, count: Map.get(counts, idea.id, 0)} end)
|> Enum.sort_by(& &1.count, :desc)
end
## Grouping mode --------------------------------------------------------
@doc """
Returns the computed grouping as `%{groups: [...], ungrouped: [...]}` where
each group is `%{leader: participant, opinion: idea, members: [participant]}`.
Only meaningful after a grouping-mode room has been closed.
"""
def group_assignments(%Room{} = room) do
members = room |> list_participants() |> Enum.filter(&(&1.role == "member"))
counts = vote_counts(room)
idea_by_participant = idea_by_participant(room)
leaders =
members
|> Enum.filter(& &1.is_leader)
|> Enum.sort_by(&(-opinion_votes(&1, idea_by_participant, counts)))
groups =
Enum.map(leaders, fn leader ->
group_members =
Enum.filter(members, fn p ->
not p.is_leader and p.assigned_leader_id == leader.id
end)
%{
leader: leader,
opinion: Map.get(idea_by_participant, leader.id),
members: group_members
}
end)
ungrouped =
Enum.filter(members, fn p -> not p.is_leader and is_nil(p.assigned_leader_id) end)
%{groups: groups, ungrouped: ungrouped}
end
defp compute_grouping(room) do
members = room |> list_participants() |> Enum.filter(&(&1.role == "member"))
counts = vote_counts(room)
idea_by_participant = idea_by_participant(room)
leaders =
Enum.filter(members, fn p ->
Map.has_key?(idea_by_participant, p.id) and
opinion_votes(p, idea_by_participant, counts) >= room.leader_threshold
end)
regular = members -- leaders
Repo.transaction(fn ->
if leaders == [] do
Enum.each(members, &set_group(&1, false, nil))
else
Enum.each(leaders, &set_group(&1, true, &1.id))
regular
|> assign_regular_members(leaders, room, idea_by_participant)
|> Enum.each(fn {member, leader_id} -> set_group(member, false, leader_id) end)
end
now = DateTime.utc_now() |> DateTime.truncate(:second)
Repo.update!(Ecto.Changeset.change(room, grouped_at: now))
end)
|> case do
{:ok, updated_room} -> updated_room
end
end
# Assigns non-leader members to leader groups minimising unmet preferences via
# the Hungarian algorithm. Returns `[{member, leader_id}]`.
defp assign_regular_members([], _leaders, _room, _idea_by_participant), do: []
defp assign_regular_members(regular, leaders, room, idea_by_participant) do
m = length(regular)
l = length(leaders)
base = div(m, l)
remainder = rem(m, l)
# Balanced capacity per leader; expanded into one slot per capacity unit.
caps = Enum.map(0..(l - 1), fn i -> base + if(i < remainder, do: 1, else: 0) end)
slot_leaders =
Enum.flat_map(Enum.zip(leaders, caps), fn {leader, cap} ->
List.duplicate(leader, cap)
end)
voted_by = votes_by_participant(room)
cost =
Enum.map(regular, fn member ->
voted = Map.get(voted_by, member.id, MapSet.new())
Enum.map(slot_leaders, fn leader ->
opinion = Map.get(idea_by_participant, leader.id)
if opinion && MapSet.member?(voted, opinion.id), do: 0, else: 1
end)
end)
{_total, assignment} = Hungarian.min_cost_assignment(cost)
regular
|> Enum.zip(assignment)
|> Enum.map(fn {member, slot_index} ->
leader = Enum.at(slot_leaders, slot_index)
{member, leader.id}
end)
end
defp set_group(participant, is_leader, leader_id) do
participant
|> Ecto.Changeset.change(is_leader: is_leader, assigned_leader_id: leader_id)
|> Repo.update!()
end
defp idea_by_participant(room) do
room
|> list_ideas()
|> Map.new(fn idea -> {idea.participant_id, idea} end)
end
defp opinion_votes(participant, idea_by_participant, counts) do
case Map.get(idea_by_participant, participant.id) do
nil -> 0
idea -> Map.get(counts, idea.id, 0)
end
end
defp votes_by_participant(room) do
Repo.all(
from v in Vote,
join: i in Idea,
on: i.id == v.idea_id,
where: i.room_id == ^room.id,
select: {v.participant_id, v.idea_id}
)
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
|> Map.new(fn {participant_id, idea_ids} -> {participant_id, MapSet.new(idea_ids)} end)
end
## Helpers --------------------------------------------------------------
defp generate_token do
:crypto.strong_rand_bytes(24) |> Base.url_encode64(padding: false)
end
defp unique_room_number(attempts \\ 0)
defp unique_room_number(attempts) when attempts < 20 do
number = Integer.to_string(:rand.uniform(900_000) + 99_999)
if Repo.exists?(from r in Room, where: r.room_number == ^number) do
unique_room_number(attempts + 1)
else
number
end
end
defp unique_room_number(_attempts) do
raise "could not allocate a unique room number"
end
defp tap_broadcast({:ok, _} = result, room, event) do
broadcast(room.id, event)
result
end
defp tap_broadcast(other, _room, _event), do: other
end
+114
View File
@@ -0,0 +1,114 @@
defmodule Rsh26pool.Voting.Hungarian do
@moduledoc """
Solves the assignment problem — a minimum-cost perfect matching in a weighted
bipartite graph — with the Hungarian (KuhnMunkres) algorithm in O(n^3).
This is the algorithm the proposal calls for when placing members into groups
in grouping mode: given a cost of assigning each member to each group slot, it
finds the assignment with the lowest total cost.
`min_cost_assignment/1` takes an `n x m` cost matrix (a list of `n` rows, each
a list of `m` numbers) with `n <= m`, and returns `{total_cost, assignment}`
where `assignment` is a list of length `n` and `Enum.at(assignment, i)` is the
0-based column matched to row `i`.
"""
@inf 1_000_000_000
@spec min_cost_assignment([[number()]]) :: {number(), [non_neg_integer()]}
def min_cost_assignment([]), do: {0, []}
def min_cost_assignment(cost) when is_list(cost) do
n = length(cost)
m = length(hd(cost))
if n > m do
raise ArgumentError, "cost matrix must have rows <= cols (got #{n} x #{m})"
end
# 1-indexed access into an immutable tuple-of-tuples.
c = cost |> Enum.map(&List.to_tuple/1) |> List.to_tuple()
cost_at = fn i, j -> c |> elem(i - 1) |> elem(j - 1) end
u = fill(n + 1, 0)
v = fill(m + 1, 0)
p = fill(m + 1, 0)
way = fill(m + 1, 0)
{_u, _v, p, _way} =
Enum.reduce(1..n, {u, v, p, way}, fn i, {u, v, p, way} ->
phase(i, m, cost_at, u, v, p, way)
end)
# p[j] holds the row matched to column j; invert to get column-per-row.
row_to_col =
Enum.reduce(1..m, %{}, fn j, acc ->
row = elem(p, j)
if row >= 1 and row <= n, do: Map.put(acc, row, j - 1), else: acc
end)
assignment = Enum.map(1..n, &Map.fetch!(row_to_col, &1))
total =
assignment
|> Enum.with_index(1)
|> Enum.reduce(0, fn {col0, i}, sum -> sum + cost_at.(i, col0 + 1) end)
{total, assignment}
end
# Augmenting phase for row `i` (see the classic e-maxx Hungarian description).
defp phase(i, m, cost_at, u, v, p, way) do
p = put_elem(p, 0, i)
minv = fill(m + 1, @inf)
used = fill(m + 1, false)
walk(0, m, cost_at, u, v, p, way, minv, used)
end
defp walk(j0, m, cost_at, u, v, p, way, minv, used) do
used = put_elem(used, j0, true)
i0 = elem(p, j0)
ui0 = elem(u, i0)
{delta, j1, minv, way} =
Enum.reduce(1..m, {@inf, -1, minv, way}, fn j, {delta, j1, minv, way} ->
if elem(used, j) do
{delta, j1, minv, way}
else
cur = cost_at.(i0, j) - ui0 - elem(v, j)
{minv, way} =
if cur < elem(minv, j),
do: {put_elem(minv, j, cur), put_elem(way, j, j0)},
else: {minv, way}
mvj = elem(minv, j)
if mvj < delta, do: {mvj, j, minv, way}, else: {delta, j1, minv, way}
end
end)
{u, v, minv} =
Enum.reduce(0..m, {u, v, minv}, fn j, {u, v, minv} ->
if elem(used, j) do
pj = elem(p, j)
{put_elem(u, pj, elem(u, pj) + delta), put_elem(v, j, elem(v, j) - delta), minv}
else
{u, v, put_elem(minv, j, elem(minv, j) - delta)}
end
end)
if elem(p, j1) == 0 do
{u, v, reconstruct(j1, p, way), way}
else
walk(j1, m, cost_at, u, v, p, way, minv, used)
end
end
defp reconstruct(j0, p, way) do
j1 = elem(way, j0)
p = put_elem(p, j0, elem(p, j1))
if j1 == 0, do: p, else: reconstruct(j1, p, way)
end
defp fill(size, value), do: value |> List.duplicate(size) |> List.to_tuple()
end
+27
View File
@@ -0,0 +1,27 @@
defmodule Rsh26pool.Voting.Idea do
@moduledoc """
A voting option. In normal mode it is an idea posted by a participant; in
grouping mode it is the participant's opinion (one per participant).
"""
use Ecto.Schema
import Ecto.Changeset
alias Rsh26pool.Voting.{Participant, Room, Vote}
schema "ideas" do
field :text, :string
belongs_to :room, Room
belongs_to :participant, Participant
has_many :votes, Vote
timestamps(type: :utc_datetime)
end
def changeset(idea, attrs) do
idea
|> cast(attrs, [:text])
|> validate_required([:text], message: "内容を入力してください")
|> validate_length(:text, max: 200, message: "200文字以内で入力してください")
end
end
+37
View File
@@ -0,0 +1,37 @@
defmodule Rsh26pool.Voting.Participant do
@moduledoc """
A user taking part in a room. Identified across requests by a random `token`
stored in the session cookie. Owners have `role == "owner"`.
"""
use Ecto.Schema
import Ecto.Changeset
alias Rsh26pool.Voting.Room
schema "participants" do
field :token, :string
field :name, :string
field :role, :string, default: "member"
# Grouping-mode results, filled in when voting closes.
field :is_leader, :boolean, default: false
belongs_to :room, Room
belongs_to :assigned_leader, __MODULE__, foreign_key: :assigned_leader_id
timestamps(type: :utc_datetime)
end
@doc "Changeset for the user-supplied fields (their display name)."
def changeset(participant, attrs) do
participant
|> cast(attrs, [:name])
|> validate_required([:name], message: "名前を入力してください")
|> validate_length(:name, max: 40, message: "名前は40文字以内で入力してください")
|> unique_constraint(:token)
end
@doc "Owner? convenience predicate."
def owner?(%__MODULE__{role: "owner"}), do: true
def owner?(%__MODULE__{}), do: false
end
+81
View File
@@ -0,0 +1,81 @@
defmodule Rsh26pool.Voting.Room do
@moduledoc """
A voting room. Identified publicly by a short `room_number` used in URLs.
"""
use Ecto.Schema
import Ecto.Changeset
alias Rsh26pool.Voting.{Idea, Participant}
schema "rooms" do
field :room_number, :string
field :name, :string
field :votes_per_user, :integer, default: 3
field :status, :string, default: "open"
field :grouping_mode, :boolean, default: false
field :leader_threshold, :integer, default: 2
field :closes_at, :utc_datetime
field :grouped_at, :utc_datetime
# Virtual field backing the `datetime-local` input (wall-clock JST).
field :closes_at_local, :string, virtual: true
has_many :participants, Participant
has_many :ideas, Idea
timestamps(type: :utc_datetime)
end
@doc "Changeset used when the owner creates a new room."
def create_changeset(room, attrs) do
room
|> cast(attrs, [
:name,
:votes_per_user,
:grouping_mode,
:leader_threshold,
:closes_at_local
])
|> validate_required([:name, :votes_per_user], message: "入力してください")
|> validate_length(:name, max: 80, message: "80文字以内で入力してください")
|> validate_number(:votes_per_user,
greater_than_or_equal_to: 1,
less_than_or_equal_to: 50,
message: "1〜50の数値を入力してください"
)
|> validate_number(:leader_threshold,
greater_than_or_equal_to: 1,
less_than_or_equal_to: 50,
message: "1〜50の数値を入力してください"
)
|> put_closes_at()
end
@doc "Changeset for owner-editable settings on the manage page."
def settings_changeset(room, attrs) do
room
|> cast(attrs, [:name])
|> validate_required([:name], message: "入力してください")
|> validate_length(:name, max: 80, message: "80文字以内で入力してください")
end
defp put_closes_at(changeset) do
case get_field(changeset, :closes_at_local) do
blank when blank in [nil, ""] ->
put_change(changeset, :closes_at, nil)
value ->
case Rsh26pool.JST.parse_local(value) do
{:ok, dt} ->
if DateTime.compare(dt, DateTime.utc_now()) == :gt do
put_change(changeset, :closes_at, dt)
else
add_error(changeset, :closes_at_local, "未来の日時を指定してください")
end
:error ->
add_error(changeset, :closes_at_local, "日時の形式が正しくありません")
end
end
end
end
+29
View File
@@ -0,0 +1,29 @@
defmodule Rsh26pool.Voting.Vote do
@moduledoc """
A single vote cast by a participant for an idea. A participant may vote for a
given idea at most once (enforced by a unique index).
"""
use Ecto.Schema
import Ecto.Changeset
alias Rsh26pool.Voting.{Idea, Participant}
schema "votes" do
belongs_to :idea, Idea
belongs_to :participant, Participant
timestamps(type: :utc_datetime)
end
def changeset(vote, attrs) do
vote
|> cast(attrs, [:idea_id, :participant_id])
|> validate_required([:idea_id, :participant_id])
|> unique_constraint([:idea_id, :participant_id],
name: :votes_idea_id_participant_id_index,
message: "は既に投票済みです"
)
|> foreign_key_constraint(:idea_id)
|> foreign_key_constraint(:participant_id)
end
end
+99
View File
@@ -0,0 +1,99 @@
defmodule Rsh26poolWeb.Auth do
@moduledoc """
Token-based authentication.
When a user creates or joins a room we generate a random token, persist it on
their `Participant` row, and store that token in the session cookie. On every
request the token is looked up to identify the current participant. This is
the token scheme described in the proposal (a random string whose owner is
recorded in the database, kept in a cookie).
"""
use Rsh26poolWeb, :verified_routes
import Plug.Conn
alias Phoenix.LiveView
alias Rsh26pool.Voting
@token_key "participant_token"
## Plug (controllers) ---------------------------------------------------
@doc "Assigns `:current_participant` from the session token (may be nil)."
def fetch_current_participant(conn, _opts) do
participant = participant_from_token(get_session(conn, @token_key))
assign(conn, :current_participant, participant)
end
@doc "Persists a participant's token in the session and assigns it."
def log_in_participant(conn, participant) do
conn
|> put_session(@token_key, participant.token)
|> assign(:current_participant, participant)
end
@doc "Clears the session token (logout)."
def log_out_participant(conn) do
conn
|> delete_session(@token_key)
|> assign(:current_participant, nil)
end
## LiveView on_mount hooks ---------------------------------------------
@doc """
`on_mount` hooks:
* `:mount_current_participant` — assigns `:current_participant` (may be nil)
* `:require_participant` — requires the viewer to belong to the room in the
URL, otherwise redirects to the enter page
* `:require_owner` — requires the viewer to be the owner of the room
"""
def on_mount(:mount_current_participant, _params, session, socket) do
{:cont, assign_current_participant(socket, session)}
end
def on_mount(:require_participant, params, session, socket) do
socket = assign_current_participant(socket, session)
participant = socket.assigns.current_participant
room_number = params["room_number"]
if participant && participant.room && participant.room.room_number == room_number do
{:cont, socket}
else
socket =
socket
|> LiveView.put_flash(:error, "このルームに参加するには入室してください。")
|> LiveView.redirect(to: ~p"/enter?#{[room: room_number]}")
{:halt, socket}
end
end
def on_mount(:require_owner, params, session, socket) do
socket = assign_current_participant(socket, session)
participant = socket.assigns.current_participant
room_number = params["room_number"]
if participant && participant.room && participant.room.room_number == room_number &&
participant.role == "owner" do
{:cont, socket}
else
socket =
socket
|> LiveView.put_flash(:error, "この管理ページにアクセスする権限がありません。")
|> LiveView.redirect(to: ~p"/")
{:halt, socket}
end
end
defp assign_current_participant(socket, session) do
Phoenix.Component.assign_new(socket, :current_participant, fn ->
participant_from_token(session[@token_key])
end)
end
defp participant_from_token(nil), do: nil
defp participant_from_token(token), do: Voting.get_participant_by_token(token)
end
+25 -31
View File
@@ -27,43 +27,39 @@ defmodule Rsh26poolWeb.Layouts do
""" """
attr :flash, :map, required: true, doc: "the map of flash messages" attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map, attr :current_participant, :map,
default: nil, default: nil,
doc: "the current [scope](https://phoenix.hexdocs.pm/scopes.html)" doc: "the currently logged-in participant, if any"
slot :inner_block, required: true slot :inner_block, required: true
def app(assigns) do def app(assigns) do
~H""" ~H"""
<header class="navbar px-4 sm:px-6 lg:px-8"> <header class="navbar bg-base-100 border-b border-base-200 px-4 sm:px-6 lg:px-8">
<div class="flex-1"> <div class="flex-1">
<a href="/" class="flex-1 flex w-fit items-center gap-2"> <.link navigate={~p"/"} class="flex items-center gap-2 text-lg font-bold">
<img src={~p"/images/logo.svg"} width="36" /> <.icon name="hero-check-badge" class="size-7 text-primary" />
<span class="text-sm font-semibold">v{Application.spec(:phoenix, :vsn)}</span> <span>みんなで投票</span>
</a> </.link>
</div> </div>
<div class="flex-none"> <div class="flex-none flex items-center gap-3">
<ul class="flex flex-column px-1 space-x-4 items-center">
<li>
<a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a>
</li>
<li>
<a href="https://github.com/phoenixframework/phoenix" class="btn btn-ghost">GitHub</a>
</li>
<li>
<.theme_toggle /> <.theme_toggle />
</li> <div :if={@current_participant} class="flex items-center gap-2">
<li> <span class="hidden sm:inline text-sm text-base-content/70">
<a href="https://phoenix.hexdocs.pm/overview.html" class="btn btn-primary"> {@current_participant.name} さん
Get Started <span aria-hidden="true">&rarr;</span> </span>
</a> <.form for={%{}} action={~p"/logout"} method="delete">
</li> <button class="btn btn-sm btn-ghost gap-1" type="submit">
</ul> <.icon name="hero-arrow-right-on-rectangle" class="size-4" />
<span class="hidden sm:inline">ログアウト</span>
</button>
</.form>
</div>
</div> </div>
</header> </header>
<main class="px-4 py-20 sm:px-6 lg:px-8"> <main class="px-4 py-10 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4"> <div class="mx-auto max-w-3xl space-y-6">
{render_slot(@inner_block)} {render_slot(@inner_block)}
</div> </div>
</main> </main>
@@ -91,7 +87,7 @@ defmodule Rsh26poolWeb.Layouts do
<.flash <.flash
id="client-error" id="client-error"
kind={:error} kind={:error}
title={gettext("We can't find the internet")} title="インターネットに接続できません"
phx-disconnected={ phx-disconnected={
show(".phx-client-error #client-error") show(".phx-client-error #client-error")
|> JS.remove_attribute("hidden", to: ".phx-client-error #client-error") |> JS.remove_attribute("hidden", to: ".phx-client-error #client-error")
@@ -99,14 +95,13 @@ defmodule Rsh26poolWeb.Layouts do
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})} phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
hidden hidden
> >
{gettext("Attempting to reconnect")} 再接続を試みています <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash> </.flash>
<.flash <.flash
id="server-error" id="server-error"
kind={:error} kind={:error}
title={gettext("Something went wrong!")} title="問題が発生しました"
phx-disconnected={ phx-disconnected={
show(".phx-server-error #server-error") show(".phx-server-error #server-error")
|> JS.remove_attribute("hidden", to: ".phx-server-error #server-error") |> JS.remove_attribute("hidden", to: ".phx-server-error #server-error")
@@ -114,8 +109,7 @@ defmodule Rsh26poolWeb.Layouts do
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})} phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
hidden hidden
> >
{gettext("Attempting to reconnect")} 再接続を試みています <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash> </.flash>
</div> </div>
""" """
@@ -1,10 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="ja">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} /> <meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="Rsh26pool" suffix=" · Phoenix Framework" phx-no-format>{assigns[:page_title]}</.live_title> <.live_title default="みんなで投票" suffix=" · 投票アプリ" phx-no-format>{assigns[:page_title]}</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}> <script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script> </script>
@@ -1,199 +1,67 @@
<Layouts.flash_group flash={@flash} /> <Layouts.app flash={@flash} current_participant={@current_participant}>
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]"> <div class="text-center py-6">
<svg <h1 class="text-4xl sm:text-5xl font-extrabold tracking-tight">
viewBox="0 0 1480 957" みんなで<span class="text-primary">投票</span>
fill="none"
aria-hidden="true"
class="absolute inset-0 h-full w-full"
preserveAspectRatio="xMinYMid slice"
>
<path fill="#EE7868" d="M0 0h1480v957H0z" />
<path
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
fill="#FF9F92"
/>
<path
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
fill="#FA8372"
/>
<path
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
fill="#E96856"
fill-opacity=".6"
/>
<path
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
fill="#C42652"
fill-opacity=".2"
/>
<path
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
fill="#A41C42"
fill-opacity=".2"
/>
<path
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
fill="#A41C42"
fill-opacity=".2"
/>
</svg>
</div>
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
<div class="mx-auto max-w-xl lg:mx-0">
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
<path
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>
<div class="mt-10 flex justify-between items-center">
<h1 class="flex items-center text-sm font-semibold leading-6">
Phoenix Framework
<small class="badge badge-warning badge-sm ml-3">
v{Application.spec(:phoenix, :vsn)}
</small>
</h1> </h1>
<Layouts.theme_toggle /> <p class="mt-4 text-base-content/70">
ルームを作って、アイデアを出し合い、好きなものに投票しよう。
</p>
</div> </div>
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance"> <div
Peace of mind from prototype to production. :if={@current_participant}
class="alert bg-base-200 border border-base-300 flex items-center justify-between"
>
<span>
現在ルーム「{@current_participant.room.name}」(番号 {@current_participant.room.room_number})に参加中です。
</span>
<.link
:if={@current_participant.role == "owner"}
navigate={~p"/manage/#{@current_participant.room.room_number}"}
class="btn btn-sm btn-primary"
>
管理ページへ
</.link>
<.link
:if={@current_participant.role != "owner"}
navigate={~p"/vote/#{@current_participant.room.room_number}"}
class="btn btn-sm btn-primary"
>
投票ページへ
</.link>
</div>
<div class="grid gap-4 sm:grid-cols-2 mt-4">
<.link
navigate={~p"/create"}
id="home-create-link"
class="card bg-base-100 border border-base-200 shadow-sm hover:shadow-md hover:border-primary transition group"
>
<div class="card-body items-center text-center">
<div class="rounded-full bg-primary/10 p-4 group-hover:bg-primary/20 transition">
<.icon name="hero-plus-circle" class="size-8 text-primary" />
</div>
<h2 class="card-title mt-2">ルームを作成</h2>
<p class="text-sm text-base-content/70">
投票の設定を決めて、新しいルームを立ち上げます。
</p> </p>
<p class="mt-4 leading-7 text-base-content/70"> </div>
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. </.link>
<.link
navigate={~p"/enter"}
id="home-enter-link"
class="card bg-base-100 border border-base-200 shadow-sm hover:shadow-md hover:border-primary transition group"
>
<div class="card-body items-center text-center">
<div class="rounded-full bg-primary/10 p-4 group-hover:bg-primary/20 transition">
<.icon name="hero-arrow-right-on-rectangle" class="size-8 text-primary" />
</div>
<h2 class="card-title mt-2">ルームに参加</h2>
<p class="text-sm text-base-content/70">
ルーム番号を入力して、既存のルームに参加します。
</p> </p>
<div class="flex">
<div class="w-full sm:w-auto">
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
<a
href="https://phoenix.hexdocs.pm/overview.html"
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105"></span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path d="m12 4 10-2v18l-10 2V4Z" fill="currentColor" fill-opacity=".15" />
<path
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Guides &amp; Docs
</span>
</a>
<a
href="https://github.com/phoenixframework/phoenix"
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105"></span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
/>
</svg>
Source Code
</span>
</a>
<a
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105"></span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path
d="M12 1v6M12 17v6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
cx="12"
cy="12"
r="4"
fill="currentColor"
fill-opacity=".15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Changelog
</span>
</a>
</div> </div>
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-base-content/80 sm:grid-cols-2"> </.link>
<div>
<a
href="https://elixirforum.com"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
</svg>
Discuss on the Elixir Forum
</a>
</div> </div>
<div> </Layouts.app>
<a
href="https://discord.gg/elixir"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
</svg>
Join our Discord server
</a>
</div>
<div>
<a
href="https://elixir-slack.community/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M3.361 10.11a1.68 1.68 0 1 1-1.68-1.681h1.68v1.682ZM4.209 10.11a1.68 1.68 0 1 1 3.361 0v4.21a1.68 1.68 0 1 1-3.361 0v-4.21ZM5.89 3.361a1.68 1.68 0 1 1 1.681-1.68v1.68H5.89ZM5.89 4.209a1.68 1.68 0 1 1 0 3.361H1.68a1.68 1.68 0 1 1 0-3.361h4.21ZM12.639 5.89a1.68 1.68 0 1 1 1.68 1.681h-1.68V5.89ZM11.791 5.89a1.68 1.68 0 1 1-3.361 0V1.68a1.68 1.68 0 0 1 3.361 0v4.21ZM10.11 12.639a1.68 1.68 0 1 1-1.681 1.68v-1.68h1.682ZM10.11 11.791a1.68 1.68 0 1 1 0-3.361h4.21a1.68 1.68 0 1 1 0 3.361h-4.21Z" />
</svg>
Join us on Slack
</a>
</div>
<div>
<a
href="https://fly.io/docs/elixir/getting-started/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 20 20"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
</svg>
Deploy your application
</a>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,89 @@
defmodule Rsh26poolWeb.RoomController do
use Rsh26poolWeb, :controller
import Phoenix.Component, only: [to_form: 1, to_form: 2]
alias Rsh26pool.Voting
alias Rsh26poolWeb.Auth
def new(conn, _params) do
render(conn, :new,
form: to_form(Voting.change_room_creation()),
page_title: "ルームを作成"
)
end
def create(conn, %{"room" => room_params}) do
case Voting.create_room(room_params) do
{:ok, %{room: room, owner: owner}} ->
conn
|> Auth.log_in_participant(owner)
|> put_flash(
:info,
"ルーム「#{room.name}」を作成しました。ルーム番号は #{room.room_number} です。"
)
|> redirect(to: ~p"/manage/#{room.room_number}")
{:error, changeset} ->
conn
|> put_flash(:error, "ルームを作成できませんでした。入力内容を確認してください。")
|> render(:new, form: to_form(changeset), page_title: "ルームを作成")
end
end
def enter_form(conn, params) do
render(conn, :enter,
form: enter_form_data(params["room"] || "", ""),
page_title: "ルームに参加"
)
end
def enter(conn, %{"enter" => %{"room_number" => number, "name" => name}}) do
number = String.trim(number || "")
name = String.trim(name || "")
cond do
number == "" or name == "" ->
conn
|> put_flash(:error, "ルーム番号と名前を入力してください。")
|> render(:enter, form: enter_form_data(number, name), page_title: "ルームに参加")
true ->
enter_room(conn, number, name)
end
end
def logout(conn, _params) do
conn
|> Auth.log_out_participant()
|> put_flash(:info, "ログアウトしました。")
|> redirect(to: ~p"/")
end
defp enter_room(conn, number, name) do
case Voting.get_room_by_number(number) do
nil ->
conn
|> put_flash(:error, "ルーム番号「#{number}」のルームが見つかりません。")
|> render(:enter, form: enter_form_data(number, name), page_title: "ルームに参加")
room ->
case Voting.join_room(room, %{"name" => name}) do
{:ok, participant} ->
conn
|> Auth.log_in_participant(participant)
|> put_flash(:info, "ルーム「#{room.name}」に参加しました。")
|> redirect(to: ~p"/vote/#{room.room_number}")
{:error, _changeset} ->
conn
|> put_flash(:error, "参加できませんでした。名前は40文字以内で入力してください。")
|> render(:enter, form: enter_form_data(number, name), page_title: "ルームに参加")
end
end
end
defp enter_form_data(number, name) do
to_form(%{"room_number" => number, "name" => name}, as: :enter)
end
end
@@ -0,0 +1,8 @@
defmodule Rsh26poolWeb.RoomHTML do
@moduledoc """
Templates rendered by `RoomController` (room creation and joining).
"""
use Rsh26poolWeb, :html
embed_templates "room_html/*"
end
@@ -0,0 +1,40 @@
<Layouts.app flash={@flash} current_participant={@current_participant}>
<div class="mx-auto max-w-md">
<.header>
ルームに参加
<:subtitle>ルーム番号とあなたの名前を入力してください。</:subtitle>
</.header>
<div class="card bg-base-100 border border-base-200 shadow-sm mt-4">
<div class="card-body">
<.form for={@form} action={~p"/enter"} method="post" id="enter-room-form">
<.input
field={@form[:room_number]}
type="text"
label="ルーム番号"
placeholder="6桁の番号"
inputmode="numeric"
required
/>
<.input
field={@form[:name]}
type="text"
label="あなたの名前"
placeholder="例:たろう"
required
/>
<div class="flex items-center justify-between gap-3 mt-2">
<.link navigate={~p"/"} class="btn btn-ghost btn-sm">
戻る
</.link>
<button type="submit" class="btn btn-primary">
<.icon name="hero-arrow-right-on-rectangle" class="size-4" /> 参加する
</button>
</div>
</.form>
</div>
</div>
</div>
</Layouts.app>
@@ -0,0 +1,65 @@
<Layouts.app flash={@flash} current_participant={@current_participant}>
<div class="mx-auto max-w-xl">
<.header>
ルームを作成
<:subtitle>投票の設定を入力してください。</:subtitle>
</.header>
<div class="card bg-base-100 border border-base-200 shadow-sm mt-4">
<div class="card-body">
<.form for={@form} action={~p"/create"} method="post" id="create-room-form">
<.input
field={@form[:name]}
type="text"
label="ルーム名"
placeholder="例:今日のランチ投票"
required
/>
<.input
field={@form[:votes_per_user]}
type="number"
label="一人あたりの投票数"
min="1"
max="50"
required
/>
<div class="divider text-sm text-base-content/60">追加オプション</div>
<.input
field={@form[:grouping_mode]}
type="checkbox"
label="グルーピングモードを有効にする(班分けアプリになります)"
/>
<.input
field={@form[:leader_threshold]}
type="number"
label="リーダーになるのに必要な票数(グルーピングモードのときのみ使用)"
min="1"
max="50"
/>
<.input
field={@form[:closes_at_local]}
type="datetime-local"
label="投票終了日時(任意・日本時間)"
/>
<p class="text-xs text-base-content/60 -mt-1 mb-3">
指定すると、その日時に自動的に投票が終了します。
</p>
<div class="flex items-center justify-between gap-3 mt-2">
<.link navigate={~p"/"} class="btn btn-ghost btn-sm">
戻る
</.link>
<button type="submit" class="btn btn-primary">
<.icon name="hero-plus" class="size-4" /> ルームを作成
</button>
</div>
</.form>
</div>
</div>
</div>
</Layouts.app>
+237
View File
@@ -0,0 +1,237 @@
defmodule Rsh26poolWeb.ManageLive do
@moduledoc """
The owner's management page: rename the room, end voting, and watch results
(with vote counts) and participants update in real time. In grouping mode the
computed teams are shown once voting has ended.
"""
use Rsh26poolWeb, :live_view
alias Rsh26pool.JST
alias Rsh26pool.Voting
@impl true
def mount(%{"room_number" => number}, _session, socket) do
room = number |> Voting.get_room_by_number() |> Voting.ensure_closed()
if connected?(socket), do: Voting.subscribe(room.id)
{:ok,
socket
|> assign(:room, room)
|> assign(:participant, socket.assigns.current_participant)
|> assign(:page_title, "管理 - #{room.name}")
|> assign(:name_form, to_form(Voting.change_room_settings(room)))
|> load_state()}
end
@impl true
def handle_event("rename", %{"room" => params}, socket) do
case Voting.update_room_settings(socket.assigns.room, params) do
{:ok, room} ->
{:noreply,
socket
|> assign(:room, room)
|> assign(:name_form, to_form(Voting.change_room_settings(room)))
|> put_flash(:info, "ルーム名を変更しました。")}
{:error, changeset} ->
{:noreply, assign(socket, :name_form, to_form(changeset))}
end
end
def handle_event("close", _params, socket) do
{:ok, room} = Voting.close_room(socket.assigns.room)
{:noreply,
socket
|> assign(:room, room)
|> put_flash(:info, "投票を終了しました。")
|> load_state()}
end
@impl true
def handle_info({:voting, :room_updated}, socket) do
room =
socket.assigns.room.room_number |> Voting.get_room_by_number() |> Voting.ensure_closed()
{:noreply, socket |> assign(:room, room) |> load_state()}
end
def handle_info({:voting, _event}, socket) do
{:noreply, load_state(socket)}
end
## State ---------------------------------------------------------------
defp load_state(socket) do
room = socket.assigns.room
open? = Voting.room_open?(room)
socket
|> assign(:open?, open?)
|> assign(:results, Voting.results(room))
|> assign(:participants, Voting.list_participants(room))
|> assign(:grouping, if(not open? and room.grouping_mode, do: Voting.group_assignments(room)))
end
## Render --------------------------------------------------------------
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_participant={@current_participant}>
<.header>
管理ページ
<:subtitle>{@room.name}</:subtitle>
<:actions>
<span :if={@open?} class="badge badge-success gap-1">
<.icon name="hero-hand-raised" class="size-4" /> 投票受付中
</span>
<span :if={not @open?} class="badge badge-neutral gap-1">
<.icon name="hero-lock-closed" class="size-4" /> 終了
</span>
</:actions>
</.header>
<div class="card bg-base-100 border border-base-200 shadow-sm">
<div class="card-body py-4">
<p class="text-sm text-base-content/70">参加者に伝えるルーム番号</p>
<p class="text-3xl font-mono font-bold tracking-widest">{@room.room_number}</p>
<p class="text-sm text-base-content/60">
投票ページ:<span class="font-mono">{url(~p"/vote/#{@room.room_number}")}</span>
</p>
<p :if={@room.closes_at} class="text-sm text-base-content/70 flex items-center gap-1 mt-1">
<.icon name="hero-clock" class="size-4" /> 自動終了予定:{JST.format(@room.closes_at)}(日本時間)
</p>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="card bg-base-100 border border-base-200 shadow-sm">
<div class="card-body py-4">
<h2 class="card-title text-base">
<.icon name="hero-pencil-square" class="size-5" /> ルーム名を変更
</h2>
<.form for={@name_form} phx-submit="rename" id="rename-form" class="flex flex-col gap-2">
<.input field={@name_form[:name]} type="text" />
<div class="flex justify-end">
<button type="submit" class="btn btn-primary btn-sm">保存</button>
</div>
</.form>
</div>
</div>
<div class="card bg-base-100 border border-base-200 shadow-sm">
<div class="card-body py-4">
<h2 class="card-title text-base">
<.icon name="hero-lock-closed" class="size-5" /> 投票の終了
</h2>
<%= if @open? do %>
<p class="text-sm text-base-content/70">
投票を終了すると、参加者は投票できなくなります。 <span :if={@room.grouping_mode}>終了時にグループ分けが行われます。</span>
</p>
<div class="flex justify-end">
<button
type="button"
phx-click="close"
data-confirm="投票を終了しますか?この操作は取り消せません。"
class="btn btn-error btn-sm"
>
投票を終了する
</button>
</div>
<% else %>
<p class="text-sm text-base-content/70">この投票は終了しています。</p>
<% end %>
</div>
</div>
</div>
<div>
<h2 class="font-semibold mb-2 flex items-center gap-2">
<.icon name="hero-trophy" class="size-5 text-warning" /> 現在の結果
<span class="badge badge-sm">{length(@results)} 件</span>
</h2>
<p :if={@results == []} class="text-base-content/60 text-sm">まだ投稿がありません。</p>
<ol class="space-y-2">
<li
:for={{result, index} <- Enum.with_index(@results)}
class={[
"flex items-center justify-between gap-3 p-3 rounded-box border",
index == 0 && @results != [] && "border-warning bg-warning/10",
index != 0 && "border-base-200 bg-base-100"
]}
>
<div class="flex items-center gap-3 min-w-0">
<span class="font-bold text-lg w-6 text-center">{index + 1}</span>
<div class="min-w-0">
<p class="truncate">{result.idea.text}</p>
<p :if={@room.grouping_mode} class="text-xs text-base-content/60">
提案者:{result.idea.participant.name}
</p>
</div>
</div>
<span class="badge badge-lg gap-1">
<.icon name="hero-hand-thumb-up" class="size-4" /> {result.count}
</span>
</li>
</ol>
</div>
<div :if={@grouping}>
<h2 class="font-semibold mb-2 flex items-center gap-2">
<.icon name="hero-user-group" class="size-5 text-primary" /> グループ分けの結果
</h2>
<p :if={@grouping.groups == []} class="text-base-content/70 text-sm">
リーダーになる人が決まりませんでした(十分な票を集めた意見がありません)。
</p>
<div class="grid gap-3 sm:grid-cols-2">
<div
:for={group <- @grouping.groups}
class="card bg-base-100 border border-base-200 shadow-sm"
>
<div class="card-body py-4">
<h3 class="font-bold flex items-center gap-2">
<.icon name="hero-user-group" class="size-5 text-primary" />
{group.leader.name} グループ
</h3>
<p class="text-sm text-base-content/70">テーマ:{group.opinion && group.opinion.text}</p>
<ul class="mt-2 space-y-1 text-sm">
<li class="flex items-center gap-2 font-semibold">
<.icon name="hero-trophy" class="size-4 text-warning" /> {group.leader.name}(リーダー)
</li>
<li :for={member <- group.members} class="flex items-center gap-2 pl-6">
{member.name}
</li>
</ul>
</div>
</div>
</div>
<p :if={@grouping.ungrouped != []} class="text-sm text-base-content/60 mt-2">
未割り当て:{Enum.map_join(@grouping.ungrouped, "、", & &1.name)}
</p>
</div>
<div>
<h2 class="font-semibold mb-2 flex items-center gap-2">
<.icon name="hero-users" class="size-5" /> 参加者
<span class="badge badge-sm">{length(@participants)} 人</span>
</h2>
<div class="flex flex-wrap gap-2">
<span
:for={p <- @participants}
class={[
"badge badge-lg",
p.role == "owner" && "badge-primary",
p.role != "owner" && "badge-ghost"
]}
>
{p.name}
<span :if={p.role == "owner"} class="ml-1 text-xs">(オーナー)</span>
</span>
</div>
</div>
</Layouts.app>
"""
end
end
+367
View File
@@ -0,0 +1,367 @@
defmodule Rsh26poolWeb.VoteLive do
@moduledoc """
The voting page. Choices are shown in a per-viewer shuffled order and their
vote counts are hidden while voting is open. In grouping mode the participant
posts a single opinion and votes for the groups they want to join; once voting
closes they see their assigned group.
"""
use Rsh26poolWeb, :live_view
alias Rsh26pool.Voting
@impl true
def mount(%{"room_number" => number}, _session, socket) do
participant = socket.assigns.current_participant
room = number |> Voting.get_room_by_number() |> Voting.ensure_closed()
if connected?(socket), do: Voting.subscribe(room.id)
{:ok,
socket
|> assign(:room, room)
|> assign(:participant, participant)
|> assign(:page_title, "投票 - #{room.name}")
|> load_state()}
end
@impl true
def handle_event("vote", %{"id" => id}, socket) do
case Voting.vote(socket.assigns.participant, String.to_integer(id)) do
{:ok, _} ->
{:noreply, refresh_votes(socket)}
{:error, :already_voted} ->
{:noreply, refresh_votes(socket)}
{:error, :limit_reached} ->
{:noreply, put_flash(socket, :error, "投票できる数の上限に達しました。")}
{:error, :closed} ->
{:noreply, socket |> put_flash(:error, "投票は終了しました。") |> reload_room()}
{:error, _} ->
{:noreply, put_flash(socket, :error, "投票できませんでした。")}
end
end
def handle_event("unvote", %{"id" => id}, socket) do
case Voting.unvote(socket.assigns.participant, String.to_integer(id)) do
:ok ->
{:noreply, refresh_votes(socket)}
{:error, :closed} ->
{:noreply, socket |> put_flash(:error, "投票は終了しました。") |> reload_room()}
{:error, _} ->
{:noreply, refresh_votes(socket)}
end
end
def handle_event("post_idea", %{"idea" => %{"text" => text}}, socket) do
case Voting.create_idea(socket.assigns.room, socket.assigns.participant, %{"text" => text}) do
{:ok, _idea} ->
message = if socket.assigns.room.grouping_mode, do: "意見を保存しました。", else: "アイデアを投稿しました。"
{:noreply, socket |> put_flash(:info, message) |> load_state()}
{:error, :closed} ->
{:noreply, socket |> put_flash(:error, "投票は終了しました。") |> reload_room()}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :idea_form, to_form(changeset, as: :idea))}
end
end
@impl true
def handle_info({:voting, :ideas_changed}, socket) do
{:noreply,
assign(
socket,
:ideas,
Voting.shuffled_ideas(socket.assigns.room, socket.assigns.participant.token)
)}
end
def handle_info({:voting, :room_updated}, socket) do
{:noreply, reload_room(socket)}
end
def handle_info({:voting, _event}, socket), do: {:noreply, socket}
## State helpers --------------------------------------------------------
defp load_state(socket) do
room = socket.assigns.room
participant = socket.assigns.participant
open? = Voting.room_open?(room)
socket
|> assign(:open?, open?)
|> assign(:votes_remaining, Voting.votes_remaining(room, participant))
|> assign(:voted_ids, Voting.voted_idea_ids(participant))
|> assign(:ideas, Voting.shuffled_ideas(room, participant.token))
|> assign_idea_form()
|> assign_results(open?)
end
defp refresh_votes(socket) do
participant = socket.assigns.participant
socket
|> assign(:voted_ids, Voting.voted_idea_ids(participant))
|> assign(:votes_remaining, Voting.votes_remaining(socket.assigns.room, participant))
end
defp reload_room(socket) do
room =
socket.assigns.room.room_number |> Voting.get_room_by_number() |> Voting.ensure_closed()
socket |> assign(:room, room) |> load_state()
end
defp assign_idea_form(socket) do
room = socket.assigns.room
if room.grouping_mode do
opinion = Voting.get_participant_idea(room, socket.assigns.participant)
text = if opinion, do: opinion.text, else: ""
socket
|> assign(:my_opinion, opinion)
|> assign(:idea_form, to_form(%{"text" => text}, as: :idea))
else
socket
|> assign(:my_opinion, nil)
|> assign(:idea_form, to_form(%{"text" => ""}, as: :idea))
end
end
defp assign_results(socket, true) do
socket |> assign(:results, []) |> assign(:grouping, nil) |> assign(:my_group, nil)
end
defp assign_results(socket, false) do
room = socket.assigns.room
if room.grouping_mode do
grouping = Voting.group_assignments(room)
socket
|> assign(:grouping, grouping)
|> assign(:my_group, find_my_group(grouping, socket.assigns.participant))
|> assign(:results, [])
else
socket
|> assign(:results, Voting.results(room))
|> assign(:grouping, nil)
|> assign(:my_group, nil)
end
end
defp find_my_group(%{groups: groups}, participant) do
Enum.find(groups, fn g ->
g.leader.id == participant.id or Enum.any?(g.members, &(&1.id == participant.id))
end)
end
defp find_my_group(_grouping, _participant), do: nil
## Render ---------------------------------------------------------------
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_participant={@current_participant}>
<.header>
{@room.name}
<:subtitle>
ルーム番号 <span class="font-mono font-semibold">{@room.room_number}</span>
<span :if={@room.grouping_mode} class="badge badge-accent badge-sm ml-2">グルーピングモード</span>
</:subtitle>
<:actions>
<span :if={@open?} class="badge badge-success gap-1">
<.icon name="hero-hand-raised" class="size-4" /> 投票受付中
</span>
<span :if={not @open?} class="badge badge-neutral gap-1">
<.icon name="hero-lock-closed" class="size-4" /> 終了
</span>
</:actions>
</.header>
<%= if @open? do %>
<div class="alert bg-base-200 border border-base-300">
<.icon name="hero-ticket" class="size-5 text-primary" />
<span>
残りの投票数:<span class="font-bold text-lg">{@votes_remaining}</span> / {@room.votes_per_user}
</span>
</div>
<div class="card bg-base-100 border border-base-200 shadow-sm">
<div class="card-body">
<h2 class="card-title text-base">
<%= if @room.grouping_mode do %>
あなたの意見(1つだけ投稿できます)
<% else %>
新しいアイデアを投稿
<% end %>
</h2>
<.form for={@idea_form} phx-submit="post_idea" id="idea-form" class="flex flex-col gap-2">
<.input
field={@idea_form[:text]}
type="text"
placeholder={if @room.grouping_mode, do: "例:みんなで公園を掃除したい", else: "例:焼肉に行きたい"}
/>
<div class="flex justify-end">
<button type="submit" class="btn btn-primary btn-sm">
<.icon name="hero-plus" class="size-4" />
{if @room.grouping_mode and @my_opinion, do: "意見を更新", else: "投稿する"}
</button>
</div>
</.form>
</div>
</div>
<div>
<h2 class="font-semibold mb-2 flex items-center gap-2">
<.icon name="hero-hand-thumb-up" class="size-5 text-primary" />
{if @room.grouping_mode, do: "参加したいグループに投票", else: "アイデアに投票"}
</h2>
<p :if={@ideas == []} class="text-base-content/60 text-sm py-6 text-center">
まだ投稿がありません。最初の一つを投稿してみましょう。
</p>
<ul class="space-y-2">
<li
:for={idea <- @ideas}
id={"idea-#{idea.id}"}
class="flex items-center justify-between gap-3 p-3 rounded-box border border-base-200 bg-base-100"
>
<div class="min-w-0">
<p class="truncate">{idea.text}</p>
<p :if={@room.grouping_mode} class="text-xs text-base-content/60">
提案者:{idea.participant.name}
</p>
</div>
<%= if MapSet.member?(@voted_ids, idea.id) do %>
<button
type="button"
phx-click="unvote"
phx-value-id={idea.id}
class="btn btn-success btn-sm gap-1 shrink-0"
>
<.icon name="hero-check-badge" class="size-4" /> 投票済み
</button>
<% else %>
<button
type="button"
phx-click="vote"
phx-value-id={idea.id}
disabled={@votes_remaining <= 0}
class="btn btn-outline btn-sm shrink-0"
>
投票する
</button>
<% end %>
</li>
</ul>
</div>
<% else %>
<div class="alert alert-info">
<.icon name="hero-lock-closed" class="size-5" />
<span>この投票は終了しました。</span>
</div>
<%= if @room.grouping_mode do %>
<.grouping_results grouping={@grouping} my_group={@my_group} participant={@participant} />
<% else %>
<.normal_results results={@results} />
<% end %>
<% end %>
</Layouts.app>
"""
end
## Result components ----------------------------------------------------
attr :results, :list, required: true
defp normal_results(assigns) do
~H"""
<div>
<h2 class="font-semibold mb-2 flex items-center gap-2">
<.icon name="hero-trophy" class="size-5 text-warning" /> 結果
</h2>
<p :if={@results == []} class="text-base-content/60 text-sm">投稿はありませんでした。</p>
<ol class="space-y-2">
<li
:for={{result, index} <- Enum.with_index(@results)}
class={[
"flex items-center justify-between gap-3 p-3 rounded-box border",
index == 0 && "border-warning bg-warning/10",
index != 0 && "border-base-200 bg-base-100"
]}
>
<div class="flex items-center gap-3 min-w-0">
<span class="font-bold text-lg w-6 text-center">{index + 1}</span>
<span class="truncate">{result.idea.text}</span>
</div>
<span class="badge badge-lg gap-1">
<.icon name="hero-hand-thumb-up" class="size-4" /> {result.count}
</span>
</li>
</ol>
</div>
"""
end
attr :grouping, :map, required: true
attr :my_group, :map, default: nil
attr :participant, :map, required: true
defp grouping_results(assigns) do
~H"""
<div class="space-y-4">
<div :if={@my_group} class="card bg-primary/10 border border-primary">
<div class="card-body py-4">
<p class="text-sm text-base-content/70">あなたのグループ</p>
<p class="text-lg font-bold flex items-center gap-2">
<.icon name="hero-user-group" class="size-5 text-primary" />
{@my_group.leader.name} さんのグループ
</p>
<p class="text-sm">テーマ:{@my_group.opinion && @my_group.opinion.text}</p>
</div>
</div>
<p :if={@grouping.groups == []} class="text-base-content/70 text-sm">
リーダーになる人が決まりませんでした(十分な票を集めた意見がありません)。
</p>
<div class="grid gap-3 sm:grid-cols-2">
<div
:for={group <- @grouping.groups}
class="card bg-base-100 border border-base-200 shadow-sm"
>
<div class="card-body py-4">
<h3 class="font-bold flex items-center gap-2">
<.icon name="hero-user-group" class="size-5 text-primary" />
{group.leader.name} グループ
</h3>
<p class="text-sm text-base-content/70">
テーマ:{group.opinion && group.opinion.text}
</p>
<ul class="mt-2 space-y-1 text-sm">
<li class="flex items-center gap-2 font-semibold">
<.icon name="hero-trophy" class="size-4 text-warning" /> {group.leader.name}(リーダー)
</li>
<li :for={member <- group.members} class="flex items-center gap-2 pl-6">
{member.name}
</li>
</ul>
</div>
</div>
</div>
</div>
"""
end
end
+19
View File
@@ -1,6 +1,8 @@
defmodule Rsh26poolWeb.Router do defmodule Rsh26poolWeb.Router do
use Rsh26poolWeb, :router use Rsh26poolWeb, :router
import Rsh26poolWeb.Auth, only: [fetch_current_participant: 2]
pipeline :browser do pipeline :browser do
plug :accepts, ["html"] plug :accepts, ["html"]
plug :fetch_session plug :fetch_session
@@ -8,6 +10,7 @@ defmodule Rsh26poolWeb.Router do
plug :put_root_layout, html: {Rsh26poolWeb.Layouts, :root} plug :put_root_layout, html: {Rsh26poolWeb.Layouts, :root}
plug :protect_from_forgery plug :protect_from_forgery
plug :put_secure_browser_headers plug :put_secure_browser_headers
plug :fetch_current_participant
end end
pipeline :api do pipeline :api do
@@ -18,6 +21,22 @@ defmodule Rsh26poolWeb.Router do
pipe_through :browser pipe_through :browser
get "/", PageController, :home get "/", PageController, :home
get "/create", RoomController, :new
post "/create", RoomController, :create
get "/enter", RoomController, :enter_form
post "/enter", RoomController, :enter
delete "/logout", RoomController, :logout
live_session :voter,
on_mount: [{Rsh26poolWeb.Auth, :require_participant}] do
live "/vote/:room_number", VoteLive
end
live_session :owner,
on_mount: [{Rsh26poolWeb.Auth, :require_owner}] do
live "/manage/:room_number", ManageLive
end
end end
# Other scopes may use custom stacks. # Other scopes may use custom stacks.
@@ -0,0 +1,58 @@
defmodule Rsh26pool.Repo.Migrations.CreateVotingTables do
use Ecto.Migration
def change do
create table(:rooms) do
add :room_number, :string, null: false
add :name, :string, null: false
add :votes_per_user, :integer, null: false, default: 3
add :status, :string, null: false, default: "open"
add :grouping_mode, :boolean, null: false, default: false
add :leader_threshold, :integer, null: false, default: 2
add :closes_at, :utc_datetime
add :grouped_at, :utc_datetime
timestamps(type: :utc_datetime)
end
create unique_index(:rooms, [:room_number])
create index(:rooms, [:status])
create index(:rooms, [:closes_at])
create table(:participants) do
add :token, :string, null: false
add :name, :string, null: false
add :role, :string, null: false, default: "member"
add :is_leader, :boolean, null: false, default: false
add :room_id, references(:rooms, on_delete: :delete_all), null: false
add :assigned_leader_id, references(:participants, on_delete: :nilify_all)
timestamps(type: :utc_datetime)
end
create unique_index(:participants, [:token])
create index(:participants, [:room_id])
create index(:participants, [:assigned_leader_id])
create table(:ideas) do
add :text, :string, null: false
add :room_id, references(:rooms, on_delete: :delete_all), null: false
add :participant_id, references(:participants, on_delete: :delete_all), null: false
timestamps(type: :utc_datetime)
end
create index(:ideas, [:room_id])
create index(:ideas, [:participant_id])
create table(:votes) do
add :idea_id, references(:ideas, on_delete: :delete_all), null: false
add :participant_id, references(:participants, on_delete: :delete_all), null: false
timestamps(type: :utc_datetime)
end
create unique_index(:votes, [:idea_id, :participant_id])
create index(:votes, [:participant_id])
end
end
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
cd -P -- "$(dirname -- "$0")"
exec ./rsh26pool eval Rsh26pool.Release.migrate
+1
View File
@@ -0,0 +1 @@
call "%~dp0\rsh26pool" eval Rsh26pool.Release.migrate
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
cd -P -- "$(dirname -- "$0")"
PHX_SERVER=true exec ./rsh26pool start
+2
View File
@@ -0,0 +1,2 @@
set PHX_SERVER=true
call "%~dp0\rsh26pool" start
+61
View File
@@ -0,0 +1,61 @@
defmodule Rsh26pool.JSTTest do
use ExUnit.Case, async: true
alias Rsh26pool.JST
describe "parse_local/1" do
test "interprets the input as wall-clock JST and returns UTC (-9h)" do
assert JST.parse_local("2026-07-04T18:00") == {:ok, ~U[2026-07-04 09:00:00Z]}
end
test "accepts a value that already carries seconds" do
assert JST.parse_local("2026-07-04T18:00:30") == {:ok, ~U[2026-07-04 09:00:30Z]}
end
test "shifts across the date boundary when JST wall time is early morning" do
# 2026-07-04 05:00 JST is the previous UTC day at 20:00.
assert JST.parse_local("2026-07-04T05:00") == {:ok, ~U[2026-07-03 20:00:00Z]}
end
test "returns :error on unparseable text" do
assert JST.parse_local("garbage") == :error
end
test "returns :error for non-binary input" do
assert JST.parse_local(nil) == :error
assert JST.parse_local(1234) == :error
end
end
describe "to_local/1 and format/1" do
test "to_local shifts a stored UTC datetime forward by 9 hours" do
assert JST.to_local(~U[2026-07-04 09:00:00Z]) == ~U[2026-07-04 18:00:00Z]
end
test "format renders the stored UTC datetime as JST wall time" do
assert JST.format(~U[2026-07-04 09:00:00Z]) == "2026/07/04 18:00"
end
test "format(nil) is the empty string" do
assert JST.format(nil) == ""
end
end
describe "parse_local |> format round-trip" do
test "is the identity on the wall clock at minute precision" do
wall_times = [
"2026-07-04T18:00",
"2026-01-01T00:00",
"2026-12-31T23:59",
"2026-03-15T09:05",
"2026-07-04T05:00"
]
for wall <- wall_times do
assert {:ok, utc} = JST.parse_local(wall)
expected = wall |> String.replace("-", "/") |> String.replace("T", " ")
assert JST.format(utc) == expected
end
end
end
end
+92
View File
@@ -0,0 +1,92 @@
defmodule Rsh26pool.Voting.HungarianTest do
use ExUnit.Case, async: true
alias Rsh26pool.Voting.Hungarian
describe "min_cost_assignment/1" do
test "empty matrix yields no cost and no assignment" do
assert Hungarian.min_cost_assignment([]) == {0, []}
end
test "raises when there are more rows than columns" do
assert_raise ArgumentError, fn ->
Hungarian.min_cost_assignment([[1, 2], [3, 4], [5, 6]])
end
end
test "square matrix matches the brute-force optimum" do
cost = [[4, 1, 3], [2, 0, 5], [3, 2, 2]]
{total, assignment} = Hungarian.min_cost_assignment(cost)
{brute_total, _} = brute_force(cost)
assert total == brute_total
assert valid_assignment?(assignment, 3, 3)
assert picked_sum(cost, assignment) == total
end
test "rectangular matrix (rows < cols) matches the brute-force optimum" do
cost = [[3, 1, 2, 4], [4, 2, 1, 0]]
{total, assignment} = Hungarian.min_cost_assignment(cost)
{brute_total, _} = brute_force(cost)
assert total == brute_total
assert valid_assignment?(assignment, 2, 4)
assert picked_sum(cost, assignment) == total
end
test "matches the brute-force optimum across many random small matrices" do
# Deterministic: fixed seed, and the failing matrix is printed for repro.
:rand.seed(:exsss, {13, 57, 911})
for _ <- 1..250 do
n = Enum.random(1..4)
m = Enum.random(n..5)
cost = for _ <- 1..n, do: for(_ <- 1..m, do: Enum.random(0..9))
{total, assignment} = Hungarian.min_cost_assignment(cost)
{brute_total, _} = brute_force(cost)
assert total == brute_total,
"cost=#{inspect(cost)} gave total #{total}, brute force found #{brute_total}"
assert valid_assignment?(assignment, n, m),
"cost=#{inspect(cost)} gave invalid assignment #{inspect(assignment)}"
assert picked_sum(cost, assignment) == total,
"cost=#{inspect(cost)}: reported total #{total} != summed picks for #{inspect(assignment)}"
end
end
end
# An assignment must be an injective mapping row -> column: length n, every
# value a distinct column in range.
defp valid_assignment?(assignment, n, m) do
length(assignment) == n and
Enum.all?(assignment, &(&1 >= 0 and &1 < m)) and
length(Enum.uniq(assignment)) == n
end
defp picked_sum(cost, assignment) do
assignment
|> Enum.with_index()
|> Enum.reduce(0, fn {col, row}, acc -> acc + (cost |> Enum.at(row) |> Enum.at(col)) end)
end
# Exhaustive optimum over every injective row->column mapping.
defp brute_force(cost) do
n = length(cost)
m = length(hd(cost))
cols = Enum.to_list(0..(m - 1))
cols
|> injective_maps(n)
|> Enum.map(fn assignment -> {picked_sum(cost, assignment), assignment} end)
|> Enum.min_by(&elem(&1, 0))
end
defp injective_maps(_cols, 0), do: [[]]
defp injective_maps(cols, k) do
for col <- cols, rest <- injective_maps(cols -- [col], k - 1), do: [col | rest]
end
end
+425
View File
@@ -0,0 +1,425 @@
defmodule Rsh26pool.VotingTest do
use Rsh26pool.DataCase, async: true
alias Rsh26pool.Voting
defp valid_room_attrs(overrides) do
Map.merge(
%{
"name" => "テストルーム",
"votes_per_user" => "3",
"grouping_mode" => "false",
"leader_threshold" => "2",
"closes_at_local" => ""
},
overrides
)
end
defp new_room(overrides \\ %{}) do
{:ok, result} = Voting.create_room(valid_room_attrs(overrides))
result
end
defp seconds_ago(n) do
DateTime.utc_now() |> DateTime.add(-n, :second) |> DateTime.truncate(:second)
end
defp set_closes_at(room, dt) do
{:ok, room} = room |> Ecto.Changeset.change(closes_at: dt) |> Repo.update()
room
end
describe "create_room/1" do
test "creates an open room with a 6-digit number and its owner" do
assert {:ok, %{room: room, owner: owner}} =
Voting.create_room(
valid_room_attrs(%{
"name" => "夏の旅行",
"votes_per_user" => "5",
"grouping_mode" => "true",
"leader_threshold" => "3"
})
)
assert room.room_number =~ ~r/^\d{6}$/
assert room.status == "open"
assert room.name == "夏の旅行"
assert room.votes_per_user == 5
assert room.grouping_mode == true
assert room.leader_threshold == 3
assert owner.role == "owner"
assert owner.name == "オーナー"
assert is_binary(owner.token) and owner.token != ""
assert owner.room_id == room.id
end
test "rejects a blank name" do
assert {:error, changeset} = Voting.create_room(valid_room_attrs(%{"name" => ""}))
assert errors_on(changeset)[:name]
end
test "rejects votes_per_user below 1" do
assert {:error, changeset} =
Voting.create_room(valid_room_attrs(%{"votes_per_user" => "0"}))
assert errors_on(changeset)[:votes_per_user]
end
test "rejects votes_per_user above 50" do
assert {:error, changeset} =
Voting.create_room(valid_room_attrs(%{"votes_per_user" => "51"}))
assert errors_on(changeset)[:votes_per_user]
end
test "a future JST closes_at_local is stored as the corresponding UTC instant" do
assert {:ok, %{room: room}} =
Voting.create_room(valid_room_attrs(%{"closes_at_local" => "2099-12-31T23:59"}))
assert room.closes_at == ~U[2099-12-31 14:59:00Z]
end
test "a past closes_at_local is rejected with an error on :closes_at_local" do
assert {:error, changeset} =
Voting.create_room(valid_room_attrs(%{"closes_at_local" => "2000-01-01T00:00"}))
assert errors_on(changeset)[:closes_at_local]
end
test "a blank closes_at_local leaves closes_at nil" do
assert {:ok, %{room: room}} =
Voting.create_room(valid_room_attrs(%{"closes_at_local" => ""}))
assert room.closes_at == nil
end
end
describe "join_room/2 and get_participant_by_token/1" do
test "creates a member with a token, found by token with its room preloaded" do
%{room: room} = new_room()
assert {:ok, participant} = Voting.join_room(room, %{"name" => "太郎"})
assert participant.role == "member"
assert participant.name == "太郎"
assert is_binary(participant.token)
found = Voting.get_participant_by_token(participant.token)
assert found.id == participant.id
assert found.room.id == room.id
end
test "get_participant_by_token(nil) returns nil" do
assert Voting.get_participant_by_token(nil) == nil
end
test "rejects a blank name" do
%{room: room} = new_room()
assert {:error, changeset} = Voting.join_room(room, %{"name" => ""})
assert errors_on(changeset)[:name]
end
end
describe "create_idea/3" do
test "normal mode allows multiple ideas from one participant" do
%{room: room} = new_room(%{"grouping_mode" => "false"})
{:ok, p} = Voting.join_room(room, %{"name" => "太郎"})
assert {:ok, i1} = Voting.create_idea(room, p, %{"text" => "焼肉に行く"})
assert {:ok, i2} = Voting.create_idea(room, p, %{"text" => "寿司を食べる"})
assert i1.id != i2.id
assert length(Voting.list_ideas(room)) == 2
end
test "grouping mode keeps a single opinion per participant (second call updates)" do
%{room: room} = new_room(%{"grouping_mode" => "true"})
{:ok, p} = Voting.join_room(room, %{"name" => "太郎"})
assert {:ok, first} = Voting.create_idea(room, p, %{"text" => "公園を掃除したい"})
assert {:ok, second} = Voting.create_idea(room, p, %{"text" => "海岸を清掃したい"})
assert first.id == second.id
assert second.text == "海岸を清掃したい"
assert length(Voting.list_ideas(room)) == 1
assert Voting.get_participant_idea(room, p).text == "海岸を清掃したい"
end
test "returns {:error, :closed} on a closed room" do
%{room: room} = new_room()
{:ok, p} = Voting.join_room(room, %{"name" => "太郎"})
{:ok, closed} = Voting.close_room(room)
assert Voting.create_idea(closed, p, %{"text" => "焼肉"}) == {:error, :closed}
end
test "returns a changeset error for blank text" do
%{room: room} = new_room()
{:ok, p} = Voting.join_room(room, %{"name" => "太郎"})
assert {:error, %Ecto.Changeset{} = changeset} =
Voting.create_idea(room, p, %{"text" => ""})
assert errors_on(changeset)[:text]
end
end
describe "voting" do
setup do
%{room: room} = new_room(%{"votes_per_user" => "2"})
{:ok, poster} = Voting.join_room(room, %{"name" => "投稿者"})
{:ok, voter} = Voting.join_room(room, %{"name" => "投票者"})
{:ok, i1} = Voting.create_idea(room, poster, %{"text" => "案1"})
{:ok, i2} = Voting.create_idea(room, poster, %{"text" => "案2"})
{:ok, i3} = Voting.create_idea(room, poster, %{"text" => "案3"})
%{room: room, poster: poster, voter: voter, ideas: [i1, i2, i3]}
end
test "a successful vote is reflected across the vote-accounting helpers", ctx do
%{room: room, voter: voter, ideas: [i1 | _]} = ctx
assert {:ok, _} = Voting.vote(voter, i1.id)
assert Voting.voted?(voter, i1.id)
assert Voting.votes_used(voter) == 1
assert Voting.votes_remaining(room, voter) == 1
assert MapSet.member?(Voting.voted_idea_ids(voter), i1.id)
end
test "voting the same idea twice returns {:error, :already_voted}", ctx do
%{voter: voter, ideas: [i1 | _]} = ctx
assert {:ok, _} = Voting.vote(voter, i1.id)
assert Voting.vote(voter, i1.id) == {:error, :already_voted}
end
test "exceeding votes_per_user returns {:error, :limit_reached}", ctx do
%{voter: voter, ideas: [i1, i2, i3]} = ctx
assert {:ok, _} = Voting.vote(voter, i1.id)
assert {:ok, _} = Voting.vote(voter, i2.id)
assert Voting.vote(voter, i3.id) == {:error, :limit_reached}
end
test "voting on a closed room returns {:error, :closed}", ctx do
%{room: room, voter: voter, ideas: [i1 | _]} = ctx
{:ok, _} = Voting.close_room(room)
assert Voting.vote(voter, i1.id) == {:error, :closed}
end
test "voting a non-existent idea returns {:error, :not_found}", ctx do
%{voter: voter} = ctx
assert Voting.vote(voter, -1) == {:error, :not_found}
end
test "voting for an idea from another room returns {:error, :not_found}", ctx do
%{voter: voter} = ctx
%{room: other} = new_room()
{:ok, other_poster} = Voting.join_room(other, %{"name" => "外部の人"})
{:ok, foreign} = Voting.create_idea(other, other_poster, %{"text" => "外部の案"})
assert Voting.vote(voter, foreign.id) == {:error, :not_found}
end
test "unvote removes the vote and frees a slot", ctx do
%{room: room, voter: voter, ideas: [i1 | _]} = ctx
{:ok, _} = Voting.vote(voter, i1.id)
assert Voting.votes_remaining(room, voter) == 1
assert Voting.unvote(voter, i1.id) == :ok
refute Voting.voted?(voter, i1.id)
assert Voting.votes_remaining(room, voter) == 2
end
test "unvote with no matching vote returns {:error, :not_found}", ctx do
%{voter: voter, ideas: [i1 | _]} = ctx
assert Voting.unvote(voter, i1.id) == {:error, :not_found}
end
test "unvote on a closed room returns {:error, :closed}", ctx do
%{room: room, voter: voter, ideas: [i1 | _]} = ctx
{:ok, _} = Voting.vote(voter, i1.id)
{:ok, _} = Voting.close_room(room)
assert Voting.unvote(voter, i1.id) == {:error, :closed}
end
test "results are sorted by count descending; vote_counts maps idea id to count", ctx do
%{room: room, poster: poster, voter: voter, ideas: [i1, i2, i3]} = ctx
# i2 => 2 votes, i1 => 1 vote, i3 => 0 votes.
{:ok, _} = Voting.vote(voter, i2.id)
{:ok, _} = Voting.vote(poster, i2.id)
{:ok, _} = Voting.vote(poster, i1.id)
counts = Voting.vote_counts(room)
assert counts[i2.id] == 2
assert counts[i1.id] == 1
assert Map.get(counts, i3.id) == nil
results = Voting.results(room)
assert Enum.map(results, & &1.count) == [2, 1, 0]
assert hd(results).idea.id == i2.id
end
test "shuffled_ideas is stable per seed and contains exactly the room's ideas", ctx do
%{room: room, ideas: ideas} = ctx
expected_ids = ideas |> Enum.map(& &1.id) |> Enum.sort()
first = Voting.shuffled_ideas(room, "viewer-token")
again = Voting.shuffled_ideas(room, "viewer-token")
assert Enum.map(first, & &1.id) == Enum.map(again, & &1.id)
assert first |> Enum.map(& &1.id) |> Enum.sort() == expected_ids
end
end
describe "close_room/1 and effective_status/1" do
test "close_room marks the room closed and is idempotent" do
%{room: room} = new_room()
assert {:ok, closed} = Voting.close_room(room)
assert closed.status == "closed"
assert {:ok, closed_again} = Voting.close_room(closed)
assert closed_again.status == "closed"
end
test "effective_status is open for an open room without a deadline" do
%{room: room} = new_room()
assert Voting.effective_status(room) == "open"
assert Voting.room_open?(room)
end
test "effective_status is closed once the status is closed" do
%{room: room} = new_room()
{:ok, closed} = Voting.close_room(room)
assert Voting.effective_status(closed) == "closed"
refute Voting.room_open?(closed)
end
test "effective_status is closed when the deadline has passed even if status is open" do
%{room: room} = new_room()
past = %{room | closes_at: seconds_ago(60)}
assert past.status == "open"
assert Voting.effective_status(past) == "closed"
refute Voting.room_open?(past)
end
test "ensure_closed closes an open room past its deadline" do
%{room: room} = new_room()
room = set_closes_at(room, seconds_ago(60))
assert room.status == "open"
closed = Voting.ensure_closed(room)
assert closed.status == "closed"
end
test "ensure_closed leaves an open room within its deadline untouched" do
%{room: room} = new_room(%{"closes_at_local" => "2099-01-01T00:00"})
still_open = Voting.ensure_closed(room)
assert still_open.status == "open"
end
test "list_rooms_to_auto_close returns only open rooms past their deadline" do
%{room: due} = new_room()
due = set_closes_at(due, seconds_ago(60))
%{room: future} = new_room(%{"closes_at_local" => "2099-01-01T00:00"})
%{room: no_deadline} = new_room()
%{room: already_closed} = new_room()
already_closed = set_closes_at(already_closed, seconds_ago(60))
{:ok, _} = Voting.close_room(already_closed)
ids = Voting.list_rooms_to_auto_close() |> Enum.map(& &1.id)
assert due.id in ids
refute future.id in ids
refute no_deadline.id in ids
refute already_closed.id in ids
end
end
describe "grouping mode" do
test "assigns members to leaders who reached the threshold, balanced by preference" do
%{room: room, owner: owner} =
new_room(%{"grouping_mode" => "true", "leader_threshold" => "2", "votes_per_user" => "3"})
{:ok, l1} = Voting.join_room(room, %{"name" => "リーダーA"})
{:ok, l2} = Voting.join_room(room, %{"name" => "リーダーB"})
{:ok, m1} = Voting.join_room(room, %{"name" => "メンバー1"})
{:ok, m2} = Voting.join_room(room, %{"name" => "メンバー2"})
{:ok, m3} = Voting.join_room(room, %{"name" => "メンバー3"})
{:ok, m4} = Voting.join_room(room, %{"name" => "メンバー4"})
{:ok, op_a} = Voting.create_idea(room, l1, %{"text" => "案A"})
{:ok, op_b} = Voting.create_idea(room, l2, %{"text" => "案B"})
{:ok, _} = Voting.create_idea(room, m1, %{"text" => "案1"})
{:ok, _} = Voting.create_idea(room, m2, %{"text" => "案2"})
{:ok, _} = Voting.create_idea(room, m3, %{"text" => "案3"})
{:ok, _} = Voting.create_idea(room, m4, %{"text" => "案4"})
# Both opinions reach the threshold of 2 (so both become leaders).
# M1 prefers BOTH opinions, M2/M3 prefer A, M4 prefers B. A naive
# "everyone to their top choice" would overload leader A (M1,M2,M3),
# so the balanced assignment must move M1 onto leader B.
{:ok, _} = Voting.vote(m1, op_a.id)
{:ok, _} = Voting.vote(m1, op_b.id)
{:ok, _} = Voting.vote(m2, op_a.id)
{:ok, _} = Voting.vote(m3, op_a.id)
{:ok, _} = Voting.vote(m4, op_b.id)
{:ok, closed} = Voting.close_room(room)
%{groups: groups, ungrouped: ungrouped} = Voting.group_assignments(closed)
leader_ids = groups |> Enum.map(& &1.leader.id) |> Enum.sort()
assert leader_ids == Enum.sort([l1.id, l2.id])
assert ungrouped == []
# Every non-leader member is placed in exactly one group.
placed = groups |> Enum.flat_map(& &1.members) |> Enum.map(& &1.id)
assert Enum.sort(placed) == Enum.sort([m1.id, m2.id, m3.id, m4.id])
assert length(placed) == length(Enum.uniq(placed))
# Balanced group sizes (differ by at most one); here forced to 2 and 2.
sizes = Enum.map(groups, &length(&1.members))
assert Enum.max(sizes) - Enum.min(sizes) <= 1
# The owner is never a leader nor placed in a group.
refute owner.id in (leader_ids ++ placed)
# A zero-cost balanced assignment exists, so every placed member ended up
# with a leader whose opinion they actually voted for.
opinion_by_leader = Map.new(groups, fn g -> {g.leader.id, g.opinion.id} end)
for group <- groups, member <- group.members do
assert MapSet.member?(Voting.voted_idea_ids(member), opinion_by_leader[group.leader.id]),
"member #{member.name} should be grouped with a leader whose opinion they voted for"
end
end
test "when no opinion reaches the threshold, there are no groups and all members are ungrouped" do
%{room: room, owner: owner} =
new_room(%{"grouping_mode" => "true", "leader_threshold" => "5", "votes_per_user" => "3"})
{:ok, a} = Voting.join_room(room, %{"name" => "A"})
{:ok, b} = Voting.join_room(room, %{"name" => "B"})
{:ok, c} = Voting.join_room(room, %{"name" => "C"})
{:ok, op_a} = Voting.create_idea(room, a, %{"text" => "案A"})
{:ok, op_b} = Voting.create_idea(room, b, %{"text" => "案B"})
{:ok, _} = Voting.create_idea(room, c, %{"text" => "案C"})
{:ok, _} = Voting.vote(a, op_b.id)
{:ok, _} = Voting.vote(b, op_a.id)
{:ok, closed} = Voting.close_room(room)
%{groups: groups, ungrouped: ungrouped} = Voting.group_assignments(closed)
assert groups == []
ungrouped_ids = ungrouped |> Enum.map(& &1.id) |> Enum.sort()
assert ungrouped_ids == Enum.sort([a.id, b.id, c.id])
refute owner.id in ungrouped_ids
end
end
end
@@ -1,8 +1,14 @@
defmodule Rsh26poolWeb.PageControllerTest do defmodule Rsh26poolWeb.PageControllerTest do
use Rsh26poolWeb.ConnCase use Rsh26poolWeb.ConnCase
test "GET /", %{conn: conn} do test "GET / renders the Japanese home with create and enter entry points", %{conn: conn} do
conn = get(conn, ~p"/") conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Peace of mind from prototype to production" response = html_response(conn, 200)
assert response =~ "みんなで"
assert response =~ "ルームを作成"
assert response =~ "ルームに参加"
assert response =~ ~p"/create"
assert response =~ ~p"/enter"
end end
end end
@@ -0,0 +1,92 @@
defmodule Rsh26poolWeb.RoomControllerTest do
use Rsh26poolWeb.ConnCase, async: true
alias Rsh26pool.Voting
defp valid_room_params(overrides \\ %{}) do
Map.merge(
%{
"name" => "テストルーム",
"votes_per_user" => "3",
"grouping_mode" => "false",
"leader_threshold" => "2",
"closes_at_local" => ""
},
overrides
)
end
describe "POST /create" do
test "valid params redirect to the manage page and log in the owner", %{conn: conn} do
conn = post(conn, ~p"/create", room: valid_room_params(%{"name" => "作成テスト"}))
path = redirected_to(conn)
assert "/manage/" <> number = path
assert number =~ ~r/^\d{6}$/
token = get_session(conn, "participant_token")
assert token
owner = Voting.get_participant_by_token(token)
assert owner.role == "owner"
assert owner.room.room_number == number
assert owner.room.name == "作成テスト"
end
test "invalid params re-render the form with a Japanese error and no login", %{conn: conn} do
conn = post(conn, ~p"/create", room: valid_room_params(%{"name" => ""}))
assert html_response(conn, 200)
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "ルームを作成できませんでした"
assert get_session(conn, "participant_token") == nil
end
end
describe "POST /enter" do
test "a valid room number and name redirect to the vote page and log in", %{conn: conn} do
{:ok, %{room: room}} = Voting.create_room(valid_room_params(%{"name" => "参加先"}))
conn = post(conn, ~p"/enter", enter: %{"room_number" => room.room_number, "name" => "花子"})
assert redirected_to(conn) == ~p"/vote/#{room.room_number}"
token = get_session(conn, "participant_token")
assert token
participant = Voting.get_participant_by_token(token)
assert participant.name == "花子"
assert participant.role == "member"
assert participant.room.id == room.id
end
test "an unknown room number stays on the page with a not-found flash", %{conn: conn} do
conn = post(conn, ~p"/enter", enter: %{"room_number" => "000000", "name" => "花子"})
assert html_response(conn, 200)
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "見つかりません"
assert get_session(conn, "participant_token") == nil
end
test "blank fields stay on the page with a validation flash", %{conn: conn} do
conn = post(conn, ~p"/enter", enter: %{"room_number" => "", "name" => ""})
assert html_response(conn, 200)
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "ルーム番号と名前を入力してください"
assert get_session(conn, "participant_token") == nil
end
end
describe "DELETE /logout" do
test "clears the session token and redirects home", %{conn: conn} do
{:ok, %{owner: owner}} = Voting.create_room(valid_room_params())
conn =
conn
|> Plug.Test.init_test_session(%{"participant_token" => owner.token})
|> delete(~p"/logout")
assert redirected_to(conn) == ~p"/"
assert get_session(conn, "participant_token") == nil
end
end
end
@@ -0,0 +1,94 @@
defmodule Rsh26poolWeb.ManageLiveTest do
use Rsh26poolWeb.ConnCase
import Phoenix.LiveViewTest
alias Rsh26pool.Voting
defp new_room(overrides \\ %{}) do
{:ok, result} =
Voting.create_room(
Map.merge(
%{
"name" => "管理ルーム",
"votes_per_user" => "2",
"grouping_mode" => "false",
"leader_threshold" => "2",
"closes_at_local" => ""
},
overrides
)
)
result
end
defp authed(conn, participant) do
Plug.Test.init_test_session(conn, %{"participant_token" => participant.token})
end
test "a non-owner member is redirected to the home page", %{conn: conn} do
%{room: room} = new_room()
{:ok, member} = Voting.join_room(room, %{"name" => "一般参加者"})
assert {:error, {:redirect, %{to: "/"}}} =
conn |> authed(member) |> live(~p"/manage/#{room.room_number}")
end
test "the owner can view vote counts, rename the room, and close voting", %{conn: conn} do
%{room: room, owner: owner} = new_room(%{"votes_per_user" => "2"})
{:ok, poster} = Voting.join_room(room, %{"name" => "投稿者"})
{:ok, idea} = Voting.create_idea(room, poster, %{"text" => "人気の案"})
{:ok, voter1} = Voting.join_room(room, %{"name" => "投票者1"})
{:ok, voter2} = Voting.join_room(room, %{"name" => "投票者2"})
{:ok, _} = Voting.vote(voter1, idea.id)
{:ok, _} = Voting.vote(voter2, idea.id)
{:ok, lv, html} = conn |> authed(owner) |> live(~p"/manage/#{room.room_number}")
assert html =~ "管理ページ"
# The result row shows the idea and its vote count of 2 (rank is 1).
result_row = lv |> element("ol li", "人気の案") |> render()
assert result_row =~ "人気の案"
assert result_row =~ "2"
# Rename via the form.
lv |> form("#rename-form", room: %{name: "新しいルーム名"}) |> render_submit()
assert render(lv) =~ "新しいルーム名"
assert Voting.reload_room(room).name == "新しいルーム名"
# Close via the close button.
refute render(lv) =~ "この投票は終了しています"
lv |> element("button[phx-click='close']") |> render_click()
assert Voting.reload_room(room).status == "closed"
assert render(lv) =~ "この投票は終了しています"
end
test "in grouping mode the group cards appear after closing", %{conn: conn} do
%{room: room, owner: owner} =
new_room(%{"grouping_mode" => "true", "leader_threshold" => "2", "votes_per_user" => "3"})
{:ok, leader} = Voting.join_room(room, %{"name" => "リーダーA"})
{:ok, m1} = Voting.join_room(room, %{"name" => "メンバー1"})
{:ok, m2} = Voting.join_room(room, %{"name" => "メンバー2"})
{:ok, opinion} = Voting.create_idea(room, leader, %{"text" => "公園で花見をする"})
{:ok, _} = Voting.create_idea(room, m1, %{"text" => "案1"})
{:ok, _} = Voting.create_idea(room, m2, %{"text" => "案2"})
{:ok, _} = Voting.vote(m1, opinion.id)
{:ok, _} = Voting.vote(m2, opinion.id)
{:ok, lv, _} = conn |> authed(owner) |> live(~p"/manage/#{room.room_number}")
# No grouping section while the room is still open.
refute render(lv) =~ "グループ分けの結果"
lv |> element("button[phx-click='close']") |> render_click()
closed_html = render(lv)
assert closed_html =~ "グループ分けの結果"
assert closed_html =~ "リーダーA グループ"
end
end
+124
View File
@@ -0,0 +1,124 @@
defmodule Rsh26poolWeb.VoteLiveTest do
use Rsh26poolWeb.ConnCase
import Phoenix.LiveViewTest
alias Rsh26pool.Voting
defp new_room(overrides \\ %{}) do
{:ok, result} =
Voting.create_room(
Map.merge(
%{
"name" => "投票ルーム",
"votes_per_user" => "2",
"grouping_mode" => "false",
"leader_threshold" => "2",
"closes_at_local" => ""
},
overrides
)
)
result
end
defp authed(conn, participant) do
Plug.Test.init_test_session(conn, %{"participant_token" => participant.token})
end
describe "access control" do
test "an unauthenticated visitor is redirected to the enter page", %{conn: conn} do
%{room: room} = new_room()
assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/vote/#{room.room_number}")
assert to =~ "/enter"
end
test "a participant of a different room is redirected", %{conn: conn} do
%{room: room} = new_room()
%{room: other} = new_room()
{:ok, outsider} = Voting.join_room(other, %{"name" => "部外者"})
assert {:error, {:redirect, %{to: to}}} =
conn |> authed(outsider) |> live(~p"/vote/#{room.room_number}")
assert to =~ "/enter"
end
end
describe "authenticated member" do
test "can render the room, post an idea, and see it appear", %{conn: conn} do
%{room: room} = new_room()
{:ok, member} = Voting.join_room(room, %{"name" => "参加者"})
{:ok, lv, html} = conn |> authed(member) |> live(~p"/vote/#{room.room_number}")
assert html =~ room.name
assert html =~ "残りの投票数"
lv |> form("#idea-form", idea: %{text: "カラオケに行こう"}) |> render_submit()
assert render(lv) =~ "カラオケに行こう"
assert [%{text: "カラオケに行こう"}] = Voting.list_ideas(room)
end
test "voting marks the idea 投票済み and records the vote", %{conn: conn} do
%{room: room} = new_room(%{"votes_per_user" => "2"})
{:ok, poster} = Voting.join_room(room, %{"name" => "投稿者"})
{:ok, idea} = Voting.create_idea(room, poster, %{"text" => "案1"})
{:ok, member} = Voting.join_room(room, %{"name" => "参加者"})
{:ok, lv, _} = conn |> authed(member) |> live(~p"/vote/#{room.room_number}")
html = lv |> element("#idea-#{idea.id} button[phx-click='vote']") |> render_click()
assert html =~ "投票済み"
assert Voting.voted?(member, idea.id)
assert Voting.votes_remaining(room, member) == 1
end
test "spending all votes disables remaining buttons and the limit is enforced", %{conn: conn} do
%{room: room} = new_room(%{"votes_per_user" => "1"})
{:ok, poster} = Voting.join_room(room, %{"name" => "投稿者"})
{:ok, i1} = Voting.create_idea(room, poster, %{"text" => "案1"})
{:ok, i2} = Voting.create_idea(room, poster, %{"text" => "案2"})
{:ok, member} = Voting.join_room(room, %{"name" => "参加者"})
{:ok, lv, _} = conn |> authed(member) |> live(~p"/vote/#{room.room_number}")
# Spend the single vote on i1.
lv |> element("#idea-#{i1.id} button[phx-click='vote']") |> render_click()
# The other idea's vote button is now disabled in the UI.
assert has_element?(lv, "#idea-#{i2.id} button[disabled]")
# And the server-side guard surfaces the limit message if a vote is forced.
html = render_click(lv, "vote", %{"id" => "#{i2.id}"})
assert html =~ "投票できる数の上限に達しました"
refute Voting.voted?(member, i2.id)
end
test "results are hidden while open and shown once the room closes", %{conn: conn} do
%{room: room} = new_room()
{:ok, poster} = Voting.join_room(room, %{"name" => "投稿者"})
{:ok, _idea} = Voting.create_idea(room, poster, %{"text" => "唯一の案"})
{:ok, member} = Voting.join_room(room, %{"name" => "参加者"})
{:ok, lv, html} = conn |> authed(member) |> live(~p"/vote/#{room.room_number}")
# While open: idea form is present, and no results ranking (<ol>) leaks.
assert has_element?(lv, "#idea-form")
refute html =~ "結果"
refute has_element?(lv, "ol")
# Closing broadcasts to the live view, which reloads and shows results.
{:ok, _} = Voting.close_room(room)
closed_html = render(lv)
assert closed_html =~ "この投票は終了しました"
assert has_element?(lv, "ol")
assert closed_html =~ "唯一の案"
refute has_element?(lv, "#idea-form")
end
end
end