Compare commits
4 Commits
34cd5ad2e7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 20375636b4 | |||
| e10a1cf717 | |||
| 8d36441a17 | |||
| ba6a0b2c17 |
@@ -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
@@ -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"]
|
||||||
@@ -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 CSS(daisyUI)を使用しています。
|
||||||
* 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
|
||||||
|
- **データベース**:PostgreSQL(Ecto)
|
||||||
|
- **フロントエンド**: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
@@ -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.
|
||||||
#
|
#
|
||||||
|
|||||||
+40
-7
@@ -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,
|
#
|
||||||
username: "postgres",
|
# When DATABASE_URL is set (see .env) we reuse its server but connect to a
|
||||||
password: "postgres",
|
# dedicated "<db>_test" database so tests never touch development data.
|
||||||
hostname: "localhost",
|
partition = System.get_env("MIX_TEST_PARTITION")
|
||||||
database: "rsh26pool_test#{System.get_env("MIX_TEST_PARTITION")}",
|
|
||||||
pool: Ecto.Adapters.SQL.Sandbox,
|
if url = System.get_env("DATABASE_URL") do
|
||||||
pool_size: System.schedulers_online() * 2
|
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",
|
||||||
|
password: "postgres",
|
||||||
|
hostname: "localhost",
|
||||||
|
database: "rsh26pool_test#{partition}",
|
||||||
|
pool: Ecto.Adapters.SQL.Sandbox,
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 (Kuhn–Munkres) 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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">
|
<.theme_toggle />
|
||||||
<li>
|
<div :if={@current_participant} class="flex items-center gap-2">
|
||||||
<a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a>
|
<span class="hidden sm:inline text-sm text-base-content/70">
|
||||||
</li>
|
{@current_participant.name} さん
|
||||||
<li>
|
</span>
|
||||||
<a href="https://github.com/phoenixframework/phoenix" class="btn btn-ghost">GitHub</a>
|
<.form for={%{}} action={~p"/logout"} method="delete">
|
||||||
</li>
|
<button class="btn btn-sm btn-ghost gap-1" type="submit">
|
||||||
<li>
|
<.icon name="hero-arrow-right-on-rectangle" class="size-4" />
|
||||||
<.theme_toggle />
|
<span class="hidden sm:inline">ログアウト</span>
|
||||||
</li>
|
</button>
|
||||||
<li>
|
</.form>
|
||||||
<a href="https://phoenix.hexdocs.pm/overview.html" class="btn btn-primary">
|
</div>
|
||||||
Get Started <span aria-hidden="true">→</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</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"
|
</h1>
|
||||||
aria-hidden="true"
|
<p class="mt-4 text-base-content/70">
|
||||||
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>
|
|
||||||
<Layouts.theme_toggle />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance">
|
|
||||||
Peace of mind from prototype to production.
|
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-4 leading-7 text-base-content/70">
|
|
||||||
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.
|
|
||||||
</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 & 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 class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-base-content/80 sm:grid-cols-2">
|
|
||||||
<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>
|
|
||||||
<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>
|
||||||
</div>
|
|
||||||
|
<div
|
||||||
|
: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>
|
||||||
|
</div>
|
||||||
|
</.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>
|
||||||
|
</div>
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
|
</Layouts.app>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd -P -- "$(dirname -- "$0")"
|
||||||
|
exec ./rsh26pool eval Rsh26pool.Release.migrate
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
call "%~dp0\rsh26pool" eval Rsh26pool.Release.migrate
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd -P -- "$(dirname -- "$0")"
|
||||||
|
PHX_SERVER=true exec ./rsh26pool start
|
||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
set PHX_SERVER=true
|
||||||
|
call "%~dp0\rsh26pool" start
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user