diff --git a/Dockerfile b/Dockerfile
index 177b8c0258c29f77f7dd74509fc2f54ee28ff690..f4221d669d7a5d80391f30f353be7ec7516c25f4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,7 +8,7 @@ RUN apt-get install nodejs && rm -rf /var/lib/apt/lists/*
 
 COPY . .
 
-RUN pip install -r requirements/base.txt -r requirements/prod.txt
+RUN pip install -r requirements/base.txt -r requirements/production.txt
 RUN npm install
 RUN npm run build
 
@@ -16,14 +16,14 @@ RUN npm run build
 RUN DATABASE_URL=postgres://x/x \
     SECRET_KEY=x \
     ALLOWED_HOSTS=x \
-    python manage.py collectstatic --noinput --settings=rybicka.settings.prod
+    python manage.py collectstatic --noinput --settings=rybicka.settings.production
 
 RUN bash -c "adduser --disabled-login --quiet --gecos app app &&  \
              chmod -R o+r /app/ && \
              chmod o+x /app/run.sh"
 USER app
 
-ENV DJANGO_SETTINGS_MODULE "rybicka.settings.prod"
+ENV DJANGO_SETTINGS_MODULE "rybicka.settings.production"
 
 EXPOSE 8000
 
diff --git a/Makefile b/Makefile
index 4dbde64bfe584eb70a83c33d63fe352f99d969a3..8a03c83d0073fdb0ce5c1cb8b3b7cac8e3bad2da 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,7 @@
 PYTHON   = python
 VENV     = .venv
 PORT     = 8012
-SETTINGS = rybicka.settings.prod
+SETTINGS = rybicka.settings.dev
 
 .PHONY: help venv install build run shell migrations migrate
 
@@ -26,7 +26,7 @@ venv: .venv/bin/python
 	${PYTHON} -m venv ${VENV}
 
 install: venv
-	${VENV}/bin/pip install -r requirements/base.txt -r requirements/prod.txt
+	${VENV}/bin/pip install -r requirements/base.txt -r requirements/production.txt
 	${VENV}/bin/nodeenv --python-virtualenv --node=19.3.0
 	${VENV}/bin/npm install
 
diff --git a/env.example b/env.example
index 5b8cb36653245921a245bda6313b083dc9a29e64..3b703bebba92d99e88b0ba05a04d7903be5c1037 100644
--- a/env.example
+++ b/env.example
@@ -2,5 +2,8 @@ DATABASE_URL="postgresql://rybicka:rybicka@localhost:5432/rybicka"
 
 SECRET_KEY="%@=^sip3=tqn6d_-xvvidc1@-t0t3&*kab@vr4c4"
 
+CHOBOTNICE_API_URL="https://chobotnice.pirati.cz/graphql/"
+CHOBOTNICE_RV_GID="R3JvdXBUeXBlOjYyNQ=="
+
 # Production settings
-ALLOWED_HOSTS="nastroje.pirati.cz"
+ALLOWED_HOSTS="tools.pirati.cz"
diff --git a/member_group_size_calc/templates/member_group_size_calc/index.html b/member_group_size_calc/templates/member_group_size_calc/index.html
index 88c19886ce94933c6a44ae689f589a33717bf8d8..6ee5581a79a89f92fe6ce80b76cd41e474a7afca 100644
--- a/member_group_size_calc/templates/member_group_size_calc/index.html
+++ b/member_group_size_calc/templates/member_group_size_calc/index.html
@@ -7,6 +7,11 @@
 {% block description %}Výpočet velikosti skupiny členů podle jednoacího řádu.{% endblock %}
 
 {% block head %}
+    <link
+        rel="stylesheet"
+        href="https://styleguide.pirati.cz/2.11.x/css/styles.css"
+    >
+
     {% render_bundle "member_group_size_calc" %}
 {% endblock %}
 
@@ -15,17 +20,17 @@
         <h1 class="text-6xl font-bebas mb-5">Kalkulačka velikosti skupiny členů</h1>
 
         <div class="bg-amber-100 p-4 flex flex-row items-center gap-4 mb-4 lg:w-[768px] md:w-full">
-            <i class="fa-solid fa-lightbulb text-3xl text-amber-800"></i>
+            <i class="ico--book text-3xl text-amber-800"></i>
             <div class="text-amber-800">
                 Tato kalkulačka slouží pro výpočet skupiny členů podle <a
                     class="underline text-amber-900"
-                    href="https://wiki.pirati.cz/rules/jdr"
+                    href="https://wiki.pirati.cz/rules/jdr#skupina_clenu"
                 >Jednacího řádu</a>.
             </div>
         </div>
 
         <div class="bg-gray-100 p-4 flex flex-row items-start gap-4 mb-4 lg:w-[768px] md:w-full">
-            <i class="fa-solid fa-circle-info text-3xl text-gray-800"></i>
+            <i class="ico--info text-3xl text-gray-800"></i>
             <div class="text-gray-800">
                 <h2 class="text-lg font-bold mb-3">Jednací řád celostátního fóra</h2>
 
@@ -123,7 +128,7 @@
         </div>
 
         <p class="font-light">
-            <i>Vypočtené hodnoty jsou zaokrouhleny na celé číslo nahoru.</i>
+            <i>Vypočtené hodnoty se zaokrouhlují na celé osoby (nahoru).</i>
         </p>
     </main>
 {% endblock %}
diff --git a/package.json b/package.json
index 62c00d323ff51eff9b20b58024cac987df959a84..4a07c0ecbe08e9ffec6d3ceb929877ce237b3726 100644
--- a/package.json
+++ b/package.json
@@ -4,9 +4,10 @@
   "description": "",
   "private": true,
   "dependencies": {
-    "@fortawesome/fontawesome-free": "^6.2.1",
     "css-loader": "^6.7.3",
     "jquery": "^3.6.3",
+    "js-cookie": "^3.0.1",
+    "select2": "^4.1.0-rc.0",
     "style-loader": "^3.3.1",
     "tailwindcss": "^3.2.4",
     "webpack": "^5.75.0",
diff --git a/requirements/base.txt b/requirements/base.txt
index 150dea4b81c612a8cca7f11e79a8ebfaec5a9ce5..fe6b002554d53683818bd6e20d728659bead4bb7 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -1,6 +1,9 @@
 Django==4.1.5
 django-database-url==1.0.3
 django-environ==0.9.0
-psycopg2-binary==2.9.5
 django-webpack-loader==1.8.0
+django-http-exceptions==1.4.0
+gql[requests]==3.4.0
 nodeenv==1.7.0
+psycopg2-binary==2.9.5
+requests==2.28.2
diff --git a/requirements/prod.txt b/requirements/production.txt
similarity index 100%
rename from requirements/prod.txt
rename to requirements/production.txt
diff --git a/rv_voting_calc/templates/member_group_size_calc/index.html b/rv_voting_calc/templates/member_group_size_calc/index.html
deleted file mode 100644
index 4a0ef689e80cd0a66d76fa42a485ff28f1f84f10..0000000000000000000000000000000000000000
--- a/rv_voting_calc/templates/member_group_size_calc/index.html
+++ /dev/null
@@ -1,17 +0,0 @@
-{% extends "shared/base.html" %}
-
-{% load render_bundle from webpack_loader %}
-
-{% block title %}Kalkulačka hlasování RV{% endblock %}
-{% block header_name %}Hlasování RV{% endblock %}
-{% block description %}TODO - Popis{% endblock %}
-
-{% block head %}
-    {% render_bundle "rv_voting_calc" %}
-{% endblock %}
-
-{% block content %}
-    <main>
-        
-    </main>
-{% endblock %}
diff --git a/rv_voting_calc/templates/rv_voting_calc/combined_steps.html b/rv_voting_calc/templates/rv_voting_calc/combined_steps.html
new file mode 100644
index 0000000000000000000000000000000000000000..394215e538e4840986e5b5e7302a7771bbcaacbe
--- /dev/null
+++ b/rv_voting_calc/templates/rv_voting_calc/combined_steps.html
@@ -0,0 +1,15 @@
+{% comment %}
+    It's fine to pass in |safe here, as the template variables have
+    already been sanitized once, as each step got rendered.
+{% endcomment %}
+
+{{ html_steps|safe }}
+
+<div id="md-steps" class="hidden">
+    {% for md_step in md_steps %}
+{{ md_step }}{% if not forloop.last %}
+
+---
+
+{% endif %}{% endfor %}
+</div>
diff --git a/rv_voting_calc/templates/rv_voting_calc/html_steps/found_popularity_winner.html b/rv_voting_calc/templates/rv_voting_calc/html_steps/found_popularity_winner.html
new file mode 100644
index 0000000000000000000000000000000000000000..09738a53692c9e0ade2e236a00f5ff4a20fa0731
--- /dev/null
+++ b/rv_voting_calc/templates/rv_voting_calc/html_steps/found_popularity_winner.html
@@ -0,0 +1,3 @@
+<li class="bg-green-200 drop-shadow-md p-4 font-semibold text-center text-xl">
+    Možnost {{ option_key }} vyhrává s {{ option.vote_count }} hlasy.
+</li>
diff --git a/rv_voting_calc/templates/rv_voting_calc/html_steps/initial_sort.html b/rv_voting_calc/templates/rv_voting_calc/html_steps/initial_sort.html
new file mode 100644
index 0000000000000000000000000000000000000000..097d7606bc6f0f800cd4c3e79785fdaa9026fc47
--- /dev/null
+++ b/rv_voting_calc/templates/rv_voting_calc/html_steps/initial_sort.html
@@ -0,0 +1,11 @@
+<li class="bg-gray-100 drop-shadow-md p-4">
+    <div class="pb-2 mb-2 border-b border-gray-300">
+        <h2 class="text-2xl font-semibold font-bebas">Počáteční rozdělení</h2>
+        <small class="text-sm">
+            <strong>{{ total_ticket_count }}</strong> lístků,
+            hranice pro nadpoloviční podporu <strong>{{ has_support_treshold }}</strong>
+        </small>
+    </div>
+
+    {% include "rv_voting_calc/includes/option_list.html" with options=options options_by_member=options_by_member show_proposers=True show_has_support=True %}
+</li>
diff --git a/rv_voting_calc/templates/rv_voting_calc/html_steps/no_winner.html b/rv_voting_calc/templates/rv_voting_calc/html_steps/no_winner.html
new file mode 100644
index 0000000000000000000000000000000000000000..bb9560d3854f8013c2868f1c0005164684b5fb62
--- /dev/null
+++ b/rv_voting_calc/templates/rv_voting_calc/html_steps/no_winner.html
@@ -0,0 +1,3 @@
+<li class="bg-red-200 drop-shadow-md p-4 font-semibold text-center text-xl">
+    Ani jedna možnost nevyhrává.
+</li>
diff --git a/rv_voting_calc/templates/rv_voting_calc/html_steps/removed_option.html b/rv_voting_calc/templates/rv_voting_calc/html_steps/removed_option.html
new file mode 100644
index 0000000000000000000000000000000000000000..062e07c61a4e3f49d8e3c68e80fe29b8b8d97acb
--- /dev/null
+++ b/rv_voting_calc/templates/rv_voting_calc/html_steps/removed_option.html
@@ -0,0 +1,11 @@
+{% load index %}
+
+<li class="bg-gray-100 drop-shadow-md p-4">
+    <div class="pb-2 mb-3 border-b border-gray-300">
+        <h2 class="text-2xl font-semibold font-bebas">{{ iteration|add:1 }}. vyřazovací kolo</h2>
+    </div>
+    <p class="mb-2 text-red-600 font-semibold">
+        Možnost {{ option_key }} bude {% if randomly %}losováním{% else %}kvůli nepopularitě{% endif %} odstraněna
+    </p>
+    {% include "rv_voting_calc/includes/option_list.html" with options=options options_by_member=options_by_member show_proposers=True show_marked_for_deletion=True %}
+</li>
diff --git a/rv_voting_calc/templates/rv_voting_calc/html_steps/with_support.html b/rv_voting_calc/templates/rv_voting_calc/html_steps/with_support.html
new file mode 100644
index 0000000000000000000000000000000000000000..6f3367a1c992d8cb47f9a8b1a1181d980756930b
--- /dev/null
+++ b/rv_voting_calc/templates/rv_voting_calc/html_steps/with_support.html
@@ -0,0 +1,11 @@
+<li class="bg-gray-100 drop-shadow-md p-4">
+    <div class="pb-2 mb-2 border-b border-gray-300">
+        <h2 class="text-2xl font-semibold font-bebas">Po vyřazení možností bez nadpoloviční podpory</h2>
+    </div>
+
+    {% if options_with_support_count != 0 %}
+        {% include "rv_voting_calc/includes/option_list.html" with options=options options_by_member=options_by_member %}
+    {% else %}
+        <p class="text-gray-800 mt-3">Žádné možnosti nemají nadpoloviční podporu.</p>
+    {% endif %}
+</li>
diff --git a/rv_voting_calc/templates/rv_voting_calc/includes/option_list.html b/rv_voting_calc/templates/rv_voting_calc/includes/option_list.html
new file mode 100644
index 0000000000000000000000000000000000000000..1e77bca301e571b13d271aa0f3572d5381b7a8ff
--- /dev/null
+++ b/rv_voting_calc/templates/rv_voting_calc/includes/option_list.html
@@ -0,0 +1,38 @@
+{% load index %}
+
+<ul class="flex flex-col gap-2">
+    {% for option_key, option in options.items %}
+        {% if not option.marked_for_deletion or show_marked_for_deletion %}
+            {% if option.has_support or show_has_support %}
+                <h3 class="flex gap-2 items-end text-xl font-semibold {% if option.marked_for_deletion or not option.has_support %}text-red-600{% endif %}">
+                    {{ option_key }}
+                    <span class="text-sm {% if option.marked_for_deletion or not option.has_support %}text-red-600{% else %}text-gray-600{% endif %}">
+                        {{ option.ticket_count }} lístků, {{ option.vote_count }} hlasů
+                    </span>
+                    {% if show_has_support and not option.has_support %}
+                        <span class="text-sm text-red-600">(Nemá nadpoloviční podporu)</span>
+                    {% endif %}
+                </h3>
+
+                {% if option.ticket_votes|length != 0 %}
+                    <ul class="flex gap-3 mt-1 flex-wrap">
+                        {% for vote in option.ticket_votes %}
+                            {% if not vote.hidden %}
+                                <li class="flex">
+                                    <div class="px-4 py-2 {% if not option.marked_for_deletion and option.has_support %}bg-gray-300{% else %}bg-red-300{% endif %}">
+                                        {{ rv_members|index:vote.member|index:"displayName" }}
+                                    </div>
+                                    <ul class="px-4 py-2 flex gap-1 {% if not option.marked_for_deletion and option.has_support %}bg-gray-200{% else %}bg-red-200{% endif %}">
+                                        {% for option in options_by_member|index:vote.member %}
+                                            <li>{{ option }}{% if not forloop.last %}, {% endif %}</li>
+                                        {% endfor %}
+                                    </ul>
+                                </li>
+                            {% endif %}
+                        {% endfor %}
+                    </ul>
+                {% endif %}
+            {% endif %}
+        {% endif %}
+    {% endfor %}
+</ul>
diff --git a/rv_voting_calc/templates/rv_voting_calc/includes/option_list.md b/rv_voting_calc/templates/rv_voting_calc/includes/option_list.md
new file mode 100644
index 0000000000000000000000000000000000000000..d605fed05c80226347bbae7a1512f567d7af5f88
--- /dev/null
+++ b/rv_voting_calc/templates/rv_voting_calc/includes/option_list.md
@@ -0,0 +1,6 @@
+{% load index %}
+
+{% for option_key, option in options.items %}{% if not option.marked_for_deletion or show_marked_for_deletion %}{% if option.has_support or show_has_support %}
+-  **Možnost {{ option_key }}** - {{ option.ticket_count }} lístků, {{ option.vote_count }} hlasů.{% if show_has_support and not option.has_support %} *(Nemá nadpoloviční podporu)*{% endif %}{% if option.marked_for_deletion or not option.has_support %} *(K odstranění)*{% endif %}{% if option.ticket_votes|length != 0 %} Volby: {% for vote in option.ticket_votes %}{% if not vote.hidden %}
+    - **{{ rv_members|index:vote.member|index:"displayName" }}** - přijatelné možnosti:{% for option in options_by_member|index:vote.member %}
+        - {{ option }}{% endfor %}{% endif %}{% endfor %}{% endif %}{% endif %}{% endif %}{% endfor %}
diff --git a/rv_voting_calc/templates/rv_voting_calc/index.html b/rv_voting_calc/templates/rv_voting_calc/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..367cc0921908d88d253eb13a1222d61f1c15690f
--- /dev/null
+++ b/rv_voting_calc/templates/rv_voting_calc/index.html
@@ -0,0 +1,107 @@
+{% extends "shared/base.html" %}
+
+{% load render_bundle from webpack_loader %}
+
+{% block title %}Kalkulačka hlasování RV{% endblock %}
+{% block header_name %}Hlasování RV{% endblock %}
+{% block description %}TODO - Popis{% endblock %}
+
+{% block head %}
+    {% render_bundle "rv_voting_calc" %}
+
+    <link
+        href="https://styleguide.pirati.cz/2.10.x/css/styles.css"
+        rel="stylesheet"
+        media="all"
+    >
+    <link
+        href="https://styleguide.pirati.cz/2.10.x/css/pattern-scaffolding.css"
+        rel="stylesheet"
+        media="all"
+    >
+{% endblock %}
+
+{% block content %}
+    <main>
+        <h1 class="text-6xl font-bebas mb-5">Kalkulačka hlasování RV</h1>
+
+        <div class="grid grid-cols-1 md:grid-cols-2 gap-7 mb-5 pb-4 items-center border-b border-gray-100">
+            <h2 class="text-2xl font-bebas">Hlasy členů</h2>
+
+            <div class="flex items-center gap-3 justify-between">
+                <h2 class="text-2xl font-bebas">Výsledky</h2>
+                <button
+                    class="btn disabled:cursor-progress"
+                    id="count-votes"
+                    {% if not options_by_member %}disabled{% endif %}
+                >
+                    <div class="btn__body">
+                        Vypočítat
+                    </div>
+                </button>
+            </div>
+        </div>
+
+        <div class="grid grid-cols-1 md:grid-cols-2 gap-7">
+            <ul class="flex flex-col gap-2">
+                {% for member in rv_members %}
+                    <li class="flex gap-4 items-center">
+                        <div class="basis-56 flex items-center">
+                            <i class="ico--user text-xl mr-2"></i>
+                            {{ member.displayName }}
+                        </div>
+                        <select
+                            id="{{ member.username }}-selection"
+                            class="__vote-selection grow w-full"
+                            multiple="multiple"
+                        ></select>
+                    </li>
+                {% endfor %}
+            </ul>
+
+            <div>
+                <ul
+                    class="flex flex-col gap-5"
+                    id="result"
+                >
+                    
+                </ul>
+                <div class="mt-4 flex gap-4 md:justify-end justify-center">
+                    <a
+                        class="hidden btn"
+                        id="permalink"
+                        href=""
+                    ><div class="btn__body">Permalink</div></a>
+                    <a
+                        class="hidden btn"
+                        id="download-log"
+                        download="kroky.md"
+                        href=""
+                    ><div class="btn__body">Stáhnout log</div></a>
+                </div>
+            </div>
+        </div>
+    </main>
+
+    {{ keyed_rv_members|json_script:"rv-members" }}
+    
+    <script>
+        const VOTE_CALCULATION_ENDPOINT = "{% url "rv_voting_calc:get_calculated_votes" %}"
+
+        {% if options_by_member %}
+            {% for member, selected_options in options_by_member.items %}
+                {% for selected_option in selected_options %}
+                    $(`#${$.escapeSelector("{{ member}}")}-selection`).append(
+                        new Option(
+                            "{{ selected_option }}",
+                            "{{ selected_option }}",
+                            true,
+                            true,
+                        )
+                    );
+                {% endfor %}
+                $(`#${$.escapeSelector("{{ member}}")}-selection`).trigger("change");
+            {% endfor %}
+        {% endif %}
+    </script>
+{% endblock %}
diff --git a/rv_voting_calc/templates/rv_voting_calc/md_steps/found_popularity_winner.md b/rv_voting_calc/templates/rv_voting_calc/md_steps/found_popularity_winner.md
new file mode 100644
index 0000000000000000000000000000000000000000..c01793dd7d57f33b8f4d238823146cdefaba9edb
--- /dev/null
+++ b/rv_voting_calc/templates/rv_voting_calc/md_steps/found_popularity_winner.md
@@ -0,0 +1 @@
+**Možnost {{ option_key }} vyhrává s {{ option.vote_count }} hlasy.**
diff --git a/rv_voting_calc/templates/rv_voting_calc/md_steps/initial_sort.md b/rv_voting_calc/templates/rv_voting_calc/md_steps/initial_sort.md
new file mode 100644
index 0000000000000000000000000000000000000000..f4403f8f5a5c4ba829604b9bdf8b388a26b27858
--- /dev/null
+++ b/rv_voting_calc/templates/rv_voting_calc/md_steps/initial_sort.md
@@ -0,0 +1,5 @@
+# Počáteční rozdělení
+
+**{{ total_ticket_count }}** lístků, hranice pro nadpoloviční podporu **{{ has_support_treshold }}**.
+
+{% include "rv_voting_calc/includes/option_list.md" with options=options options_by_member=options_by_member show_proposers=True show_has_support=True %}
diff --git a/rv_voting_calc/templates/rv_voting_calc/md_steps/no_winner.md b/rv_voting_calc/templates/rv_voting_calc/md_steps/no_winner.md
new file mode 100644
index 0000000000000000000000000000000000000000..01483b3565635af8f55dcc7fa1c9c4b4c4f4235d
--- /dev/null
+++ b/rv_voting_calc/templates/rv_voting_calc/md_steps/no_winner.md
@@ -0,0 +1 @@
+**Ani jedna možnost nevyhrává.**
diff --git a/rv_voting_calc/templates/rv_voting_calc/md_steps/removed_option.md b/rv_voting_calc/templates/rv_voting_calc/md_steps/removed_option.md
new file mode 100644
index 0000000000000000000000000000000000000000..1b22831c74424fafa07e7a60065047f1945e5edb
--- /dev/null
+++ b/rv_voting_calc/templates/rv_voting_calc/md_steps/removed_option.md
@@ -0,0 +1,7 @@
+{% load index %}
+
+# {{ iteration|add:1 }}. vyřazovací kolo
+
+**Možnost {{ option_key }} bude {% if randomly %}losováním{% else %}kvůli nepopularitě{% endif %} odstraněna.**
+
+{% include "rv_voting_calc/includes/option_list.md" with options=options options_by_member=options_by_member show_proposers=True show_marked_for_deletion=True %}
diff --git a/rv_voting_calc/templates/rv_voting_calc/md_steps/with_support.md b/rv_voting_calc/templates/rv_voting_calc/md_steps/with_support.md
new file mode 100644
index 0000000000000000000000000000000000000000..b55458fd1b96fd9aa6633dbcdda15694ffd5c4df
--- /dev/null
+++ b/rv_voting_calc/templates/rv_voting_calc/md_steps/with_support.md
@@ -0,0 +1,7 @@
+# Po vyřazení možností bez nadpoloviční podpory
+
+{% if options_with_support_count != 0 %}
+    {% include "rv_voting_calc/includes/option_list.md" with options=options options_by_member=options_by_member %}
+{% else %}
+    *Žádné možnosti nemají nadpoloviční podporu.*
+{% endif %}
diff --git a/rv_voting_calc/templatetags/__init__.py b/rv_voting_calc/templatetags/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/rv_voting_calc/templatetags/index.py b/rv_voting_calc/templatetags/index.py
new file mode 100644
index 0000000000000000000000000000000000000000..d20f7e2cdf360fcea86219b84130c7f5499efab5
--- /dev/null
+++ b/rv_voting_calc/templatetags/index.py
@@ -0,0 +1,10 @@
+from django import template
+register = template.Library()
+
+# https://stackoverflow.com/a/29664945
+# Thanks to WeizhongTu and Bakuutin!
+
+
+@register.filter
+def index(indexable, i):
+    return indexable[i]
diff --git a/rv_voting_calc/urls.py b/rv_voting_calc/urls.py
index 89f309aab2eddd8d5b04b0c2e8d50fe53fb1f33d..7f3f506150c7e427ab27b88ba35abad1f15f5bf6 100644
--- a/rv_voting_calc/urls.py
+++ b/rv_voting_calc/urls.py
@@ -5,4 +5,9 @@ from . import views
 app_name = "rv_voting_calc"
 urlpatterns = [
     path("", views.index, name="index"),
+    path(
+        "calculated-votes",
+        views.get_calculated_votes,
+        name="get_calculated_votes"
+    )
 ]
diff --git a/rv_voting_calc/views.py b/rv_voting_calc/views.py
index b40ece24483ddcffd2d2876c18dd5330088e6cbb..05261f0aa3e0004bc1e0bfb808989fbfb6b912f2 100644
--- a/rv_voting_calc/views.py
+++ b/rv_voting_calc/views.py
@@ -1,9 +1,647 @@
+import base64
+import binascii
+import json
+import math
+import random
+import secrets
+import urllib.parse
+
+import gql
+import requests
+
+from gql.transport.exceptions import TransportQueryError
+from gql.transport.requests import RequestsHTTPTransport
+
+from django.conf import settings
+from django.http import HttpResponse, JsonResponse
 from django.shortcuts import render
+from django.template.loader import render_to_string
+from django_http_exceptions import HTTPExceptions
+
+
+def get_options_by_member(source, rv_members) -> dict:
+    try:
+        options_by_member = json.loads(source)
+
+        # The provided votes must be a dictionary with at least 1 key.
+        if not isinstance(options_by_member, dict) or len(options_by_member) == 0:
+            raise ValueError
+    except ValueError:
+        raise HTTPExceptions.BAD_REQUEST
+
+    rv_members = convert_rv_members_to_dict(rv_members)
+
+    for member, options in options_by_member.items():
+        # `member` is not a string
+        if not isinstance(member, str):
+            raise HTTPExceptions.BAD_REQUEST
+
+        # `member` is not in the list of known members
+        if member not in options_by_member:
+            raise HTTPExceptions.BAD_REQUEST
+
+        # `options` is not a list of 1+ length
+        if not isinstance(options, list) and len(options) != 0:
+            raise HTTPExceptions.BAD_REQUEST
+
+        # all items in `options` are strings
+        for item in options:
+            if not isinstance(item, str):
+                raise HTTPExceptions.BAD_REQUEST
+
+    return options_by_member
 
-# Create your views here.
 
 def index(request):
+    rv_members = get_rv_members(request)
+    keyed_rv_members = convert_rv_members_to_dict(rv_members)
+
+    # Get votes from a possible permalink
+    options_by_member = request.GET.get("votes")
+    if options_by_member is not None:
+        options_by_member = urllib.parse.unquote(options_by_member)
+
+    # BEGIN Input validation
+    if options_by_member is not None:
+        options_by_member = get_options_by_member(
+            options_by_member,
+            rv_members=rv_members,
+        )
+
     return render(
         request,
-        "rv_voting_calc/index.html"
+        "rv_voting_calc/index.html",
+        {
+            "rv_members": rv_members,
+            "keyed_rv_members": keyed_rv_members,
+            "options_by_member": options_by_member,
+        }
+    )
+
+
+def get_rv_members(request, get_rv_gid: bool = False):
+    transport = RequestsHTTPTransport(url=settings.CHOBOTNICE_API_URL)
+    client = gql.Client(
+        transport=transport,
+        fetch_schema_from_transport=True,
+    )
+
+    rv_gid = None
+
+    if "rv_gid" in request.GET:
+        rv_gid = urllib.parse.unquote(request.GET["rv_gid"])
+
+        try:
+            base64.b64decode(rv_gid, validate=True)
+        except binascii.Error:
+            raise HTTPExceptions.BAD_REQUEST
+    else:
+        rv_gid = settings.CHOBOTNICE_RV_GID
+
+    # Get members from query
+    query = gql.gql(
+        f"""
+            {{
+                group(id: "{rv_gid}") {{
+                    memberships {{
+                        person {{
+                            username
+                            displayName
+                            officialLastName
+                        }}
+                    }}
+                }}
+            }}
+        """
+    )
+
+    try:
+        result = client.execute(query)
+    except TransportQueryError:
+        # rv_gid was not found
+        raise HTTPExceptions.BAD_REQUEST
+
+    rv_members = []
+
+    # Convert to a nicer format
+    for member in result["group"]["memberships"]:
+        rv_members.append(member["person"])
+
+    # Sort alphabetically
+    rv_members.sort(key=lambda value: value["displayName"])
+
+    if get_rv_gid:
+        return rv_members, rv_gid
+    else:
+        return rv_members
+
+
+def convert_rv_members_to_dict(source: list) -> dict:
+    rv_members = {}
+
+    for member in source:
+        # Convert to username: {data}
+        rv_members[member["username"]] = {
+            "displayName": member["displayName"],
+            "officialLastName": member["officialLastName"]
+        }
+
+    return rv_members
+
+
+def do_step_b_through_d(
+    request,
+    iteration: int,
+    html_steps: list,
+    md_steps: list,
+    options: dict,
+    options_without_support: list,
+    options_by_member: dict,
+    rv_members: dict,
+) -> tuple:
+    ## BEGIN Step B
+
+    options_marked_for_deletion = []
+
+    # Move ticket votes to the next most popular option, if necessary
+    for option_key, option in options.items():
+        votes_to_remove = []
+
+        if option["marked_for_deletion"]:
+            options_marked_for_deletion.append(option_key)
+
+        # If the option has support and isn't marked for deletion, we don't need
+        # to do anything.
+        if option["has_support"] and not option["marked_for_deletion"]:
+            continue
+
+        # For all of the option's votes...
+        for vote_list_position, vote in enumerate(option["ticket_votes"]):
+            # Find the other acceptable options listed within...
+            for other_acceptable_option in vote["other_acceptable"]:
+                # ... if the other acceptable option no longer exists, do nothing.
+                if other_acceptable_option not in options:
+                    continue
+
+                # ... if the other acceptable option has support and isn't marked
+                # for deletion, move this ticket vote there. We don't need to check
+                # positions, since the list we are looping through is ordered
+                # by popularity.
+                if (
+                    options[other_acceptable_option]["has_support"]
+                    and not options[other_acceptable_option]["marked_for_deletion"]
+                ):
+                    votes_to_remove.append(vote_list_position)
+                    options[other_acceptable_option]["ticket_votes"].append(vote)
+                    options[other_acceptable_option]["ticket_count"] += 1
+                    break
+
+        # Remove votes that have been moved from this option.
+        index_offset = 0
+
+        for vote_list_position in votes_to_remove:
+            option["ticket_votes"].pop(vote_list_position - index_offset)
+
+            index_offset += 1
+
+    # If we are on the first iteration, show the list of options with those that
+    # don't have support removed.
+    if iteration == 0:
+        # Get the number of options that have support, for displaying.
+        options_with_support_count = 0
+
+        for option in options.values():
+            if option["has_support"]:
+                options_with_support_count += 1
+
+        context = {
+            "options": options,
+            "options_by_member": options_by_member,
+            "options_with_support_count": options_with_support_count,
+            "rv_members": rv_members
+        }
+
+        html_steps.append(
+            render_to_string(
+                "rv_voting_calc/html_steps/with_support.html",
+                context,
+                request=request,
+            )
+        )
+        md_steps.append(
+            render_to_string(
+                "rv_voting_calc/md_steps/with_support.md",
+                context,
+                request=request,
+            )
+        )
+
+    # Delete options without support and those marked for deletion.
+    for option_key in (options_without_support + options_marked_for_deletion):
+        options.pop(option_key, None)
+
+    # If no options remain, show that there are no winners and end everything.
+    if len(options) == 0:
+        html_steps.append(
+            render_to_string(
+                "rv_voting_calc/html_steps/no_winner.html",
+                {},
+                request=request,
+            )
+        )
+        md_steps.append(
+            render_to_string(
+                "rv_voting_calc/md_steps/no_winner.md",
+                {},
+                request=request,
+            )
+        )
+
+        return options, True
+
+    ## END Step B
+
+    ## BEGIN Step C
+
+    # Sort options by their vote count.
+    options = {
+        key: value
+        for key, value
+        in sorted(
+            options.items(),
+            reverse=True,
+            key=lambda option: option[1]["vote_count"],
+        )
+    }
+
+    # Find winners.
+    winner_vote_count = options[next(iter(options))]["vote_count"]
+    ticket_count_same = True
+    winner_count = 0
+    winning_option_keys = []
+
+    for option_key, option in options.items():
+        if option["vote_count"] == winner_vote_count:
+            if option["ticket_count"] != options[next(iter(options))]["ticket_count"]:
+                ticket_count_same = False
+
+            winning_option_keys.append(option_key)
+            winner_count += 1
+
+    # If there is exactly 1 winner, show the winner and end everything.
+    if winner_count == 1:
+        context = {
+            "option_key": winning_option_keys[0],
+            "option": options[winning_option_keys[0]],
+        }
+
+        html_steps.append(
+            render_to_string(
+                "rv_voting_calc/html_steps/found_popularity_winner.html",
+                context,
+                request=request,
+            )
+        )
+        md_steps.append(
+            render_to_string(
+                "rv_voting_calc/md_steps/found_popularity_winner.md",
+                context,
+                request=request,
+            )
+        )
+
+        return options, True
+    # If all options are winners and have the same ticket vote count,
+    # randomly eliminate one and continue to the next iteration.
+    elif winner_count == len(options) and ticket_count_same:
+        eliminated_winner = random.choice(winning_option_keys)
+        
+        options[eliminated_winner]["marked_for_deletion"] = True
+
+        context = {
+            "iteration": iteration,
+            "option_key": eliminated_winner,
+            "option": options[eliminated_winner],
+            "options": options,
+            "options_by_member": options_by_member,
+            "rv_members": rv_members,
+            "randomly": True
+        }
+
+        html_steps.append(
+            render_to_string(
+                "rv_voting_calc/html_steps/removed_option.html",
+                context,
+                request=request,
+            )
+        )
+        md_steps.append(
+            render_to_string(
+                "rv_voting_calc/md_steps/removed_option.md",
+                context,
+                request=request,
+            )
+        )
+
+        return options, False
+
+    ## END Step C
+
+    ## BEGIN Step D
+
+    # Sort options by their vote count, with lowest ranking ones first.
+    options = {
+        key: value
+        for key, value
+        in sorted(
+            options.items(),
+            key=lambda option: option[1]["vote_count"],
+        )
+    }
+
+    # Find losing options.
+    loser_vote_count = options[next(iter(options))]["vote_count"]
+    loser_count = 0
+    losing_option_keys = []
+
+    for option_key, option in options.items():
+        if option["vote_count"] == loser_vote_count:
+            losing_option_keys.append(option_key)
+            loser_count += 1
+
+    # If there is exactly one losing option, delete it and move on to the next
+    # iteration.
+    if loser_count == 1:
+        loser_key = next(iter(options))
+
+        options[loser_key]["marked_for_deletion"] = True
+
+        context = {
+            "iteration": iteration,
+            "option_key": loser_key,
+            "option": options[loser_key],
+            "options": options,
+            "options_by_member": options_by_member,
+            "rv_members": rv_members,
+        }
+
+        html_steps.append(
+            render_to_string(
+                "rv_voting_calc/html_steps/removed_option.html",
+                context,
+                request=request,
+            )
+        )
+        md_steps.append(
+            render_to_string(
+                "rv_voting_calc/md_steps/removed_option.md",
+                context,
+                request=request,
+            )
+        )
+
+        return options, False
+    else:
+        # If there are multiple losing options, order them by the amount of
+        # ticket votes they have.
+        losing_option_keys.sort(
+            key=lambda key: options[key]["ticket_count"]
+        )
+
+        # Delete the vote with the least ticket votes. There maybe more, but
+        # those should be taken care of in the next iteration.
+        options[losing_option_keys[0]]["marked_for_deletion"] = True
+
+        context = {
+            "iteration": iteration,
+            "option_key": losing_option_keys[0],
+            "option": options[losing_option_keys[0]],
+            "options": options,
+            "options_by_member": options_by_member,
+            "rv_members": rv_members,
+        }
+
+        html_steps.append(
+            render_to_string(
+                "rv_voting_calc/html_steps/removed_option.html",
+                context,
+                request=request,
+            )
+        )
+        md_steps.append(
+            render_to_string(
+                "rv_voting_calc/md_steps/removed_option.md",
+                context,
+                request=request,
+            )
+        )
+
+        # End this iteration and move to the next one.
+        return options, False
+
+    ## END Step D
+
+    # (TODO: Will this function ever actually reach the code below?)
+
+    # If there are no winners, show that and end everything.
+    if len(options) == 0:
+        html_steps.append(
+            render_to_string(
+                "rv_voting_calc/html_steps/no_winner.html",
+                {},
+                request=request,
+            )
+        )
+        md_steps.append(
+            render_to_string(
+                "rv_voting_calc/md_steps/no_winner.md",
+                {},
+                request=request,
+            )
+        )
+
+        return options, True
+
+    # Continue on to the next iteration.
+    return options, False
+
+
+def get_calculated_votes(request):
+    options_by_member = request.GET.get("votes")
+    if options_by_member is not None:
+        options_by_member = urllib.parse.unquote(options_by_member)
+
+    # BEGIN Input validation
+
+    if options_by_member is None:
+        raise HTTPExceptions.BAD_REQUEST
+
+    rv_members, rv_gid = get_rv_members(request, get_rv_gid=True)
+    options_by_member = get_options_by_member(
+        options_by_member,
+        rv_members=rv_members,
     )
+    rv_members = convert_rv_members_to_dict(rv_members)
+
+    # END Input validation
+
+    ## BEGIN Step A
+    # Sorting
+
+    html_steps = []
+    md_steps = []
+
+    options = {}
+    total_ticket_count = len(options_by_member)  # 1 member = 1 ticket
+    has_support_treshold = math.ceil(total_ticket_count * 0.5)
+
+    # If the ticket count is divisible by 2, add 1 to make the treshold >half
+    if total_ticket_count % 2 == 0:
+        has_support_treshold += 1
+
+    # For each member's selected options...
+    for member, selected_options in options_by_member.items():
+        for position, option in enumerate(selected_options):
+            # Add the option to the options list if it isn't there already.
+            if option not in options:
+                options[option] = {
+                    "ticket_count": 0,
+                    "vote_count": 0,
+                    "marked_for_deletion": False,
+                    "has_support": False,
+                    "ticket_votes": [],
+                }
+
+            # If this vote isn't a ticket vote, don't do anything else.
+            if position != 0:
+                continue
+
+            # Add this vote to the option's ticket votes.
+            options[option]["ticket_votes"].append({
+                "member": member,
+                "other_acceptable": selected_options[1:]
+            })
+            # Increase the vote and ticket vote counters.
+            options[option]["ticket_count"] += 1
+            options[option]["vote_count"] += 1
+
+            # For all other acceptable options the member selected...
+            for other_acceptable_option in selected_options[1:]:
+                # If the option isn't among in the options list, add it.
+                if other_acceptable_option not in options:
+                    options[other_acceptable_option] = {
+                        "ticket_count": 0,
+                        "vote_count": 0,
+                        "marked_for_deletion": False,
+                        "has_support": False,
+                        "ticket_votes": [],
+                    }
+
+                # Increase the option's vote counter.
+                options[other_acceptable_option]["vote_count"] += 1
+
+    # Mark options that don't meet the >half acceptability treshold as such.
+    options_without_support = []
+
+    for option_key, option in options.items():
+        if option["vote_count"] >= has_support_treshold:
+            option["has_support"] = True
+
+        if not option["has_support"]:
+            options_without_support.append(option_key)
+            continue
+
+    # Order the options based on whether they have support or not.
+    # This is purely for aesthetic purpose.
+    options = {
+        key: value
+        for key, value
+        in sorted(
+            options.items(),
+            reverse=True,
+            key=lambda option: option[1]["has_support"],
+        )
+    }
+
+    context = {
+        "options": options,
+        "options_by_member": options_by_member,
+        "rv_members": rv_members,
+        "total_ticket_count": total_ticket_count,
+        "has_support_treshold": has_support_treshold
+    }
+
+    html_steps.append(
+        render_to_string(
+            "rv_voting_calc/html_steps/initial_sort.html",
+            context,
+            request=request,
+        )
+    )
+    md_steps.append(
+        render_to_string(
+            "rv_voting_calc/md_steps/initial_sort.md",
+            context,
+            request=request,
+        )
+    )
+
+    ## END Step A
+
+    # Make sure we can always repeat a previously made calculation. This is also
+    # useful for permalinks.
+
+    # First, try to find the seed in the URL parameters
+    seed = request.GET.get(
+        "seed",
+        # Then, try to find it in the cookies
+        request.COOKIES.get(
+            "seed",
+            # If neither are set, generate a default.
+            base64.b64encode(secrets.token_bytes()).decode("utf-8")
+        )
+    )
+
+    # Unquote the seed if it was found in the URL parameters
+    if "seed" in request.GET:
+        seed = urllib.parse.unquote(seed)
+
+    random.seed(seed)
+
+    print(seed)
+
+    # Continue until steps B - D have reached a final conclusion and count
+    # the amount of times the function ran to achieve this.
+    iteration = 0
+
+    while True:
+        options, ended = do_step_b_through_d(
+            request,
+            iteration,
+            html_steps,
+            md_steps,
+            options,
+            options_without_support,
+            options_by_member,
+            rv_members,
+        )
+
+        if ended:
+            response = render(
+                request,
+                "rv_voting_calc/combined_steps.html",
+                {
+                    "html_steps": "\n".join(html_steps),
+                    "md_steps": md_steps
+                }
+            )
+
+            # Set the random seed cookie to the one used in the calculation.
+            response.set_cookie("seed", seed)
+            # Do the same for RV's GID.
+            response.set_cookie("rv_gid", rv_gid)
+
+            return response
+
+        # Step E
+        iteration += 1    
diff --git a/rybicka/settings/base.py b/rybicka/settings/base.py
index 8eb3f074efd718e15cae089d7e2d56c583680aa8..9d59ae05d40982c7e2da515a79e225197e7ba1de 100644
--- a/rybicka/settings/base.py
+++ b/rybicka/settings/base.py
@@ -47,6 +47,7 @@ INSTALLED_APPS = [
 
     "shared",
     "member_group_size_calc",
+    "rv_voting_calc",
 ]
 
 MIDDLEWARE = [
@@ -56,6 +57,9 @@ MIDDLEWARE = [
     "django.middleware.csrf.CsrfViewMiddleware",
     "django.contrib.messages.middleware.MessageMiddleware",
     "django.middleware.clickjacking.XFrameOptionsMiddleware",
+
+    "django_http_exceptions.middleware.ExceptionHandlerMiddleware",
+    "django_http_exceptions.middleware.ThreadLocalRequestMiddleware",
 ]
 
 ROOT_URLCONF = "rybicka.urls"
@@ -112,3 +116,11 @@ WEBPACK_LOADER = {
         "IGNORE": [r".+\.hot-update.js", r".+\.map"],
     }
 }
+
+# Chobotnice
+
+CHOBOTNICE_API_URL=env.str(
+    "CHOBOTNICE_API_URL",
+    "https://chobotnice.pirati.cz/graphql/"
+)
+CHOBOTNICE_RV_GID=env.str("CHOBOTNICE_RV_GID")
diff --git a/rybicka/settings/prod.py b/rybicka/settings/production.py
similarity index 100%
rename from rybicka/settings/prod.py
rename to rybicka/settings/production.py
diff --git a/rybicka/urls.py b/rybicka/urls.py
index fbfedf16485e96b0d616556486740e77b2148b92..c5add90411d59cf57d2885ad5030aaccffb7ba41 100644
--- a/rybicka/urls.py
+++ b/rybicka/urls.py
@@ -16,6 +16,7 @@ Including another URLconf
 from django.urls import include, path
 
 urlpatterns = [
-    path("vypocet-skupiny-clenu", include("member_group_size_calc.urls")),
+    path("vypocet-skupiny-clenu/", include("member_group_size_calc.urls")),
+    path("hlasovani-rv/", include("rv_voting_calc.urls")),
     path("", include("shared.urls")),
 ]
diff --git a/shared/static/shared/scissors.webp b/shared/static/shared/scissors.webp
new file mode 100644
index 0000000000000000000000000000000000000000..d81ed816900645b471977afec959b0bb8fe33dd9
Binary files /dev/null and b/shared/static/shared/scissors.webp differ
diff --git a/shared/static/shared/voting.webp b/shared/static/shared/voting.webp
new file mode 100644
index 0000000000000000000000000000000000000000..faf0b02ba0e4cbbd6bf479cfc9a4c78234132c12
Binary files /dev/null and b/shared/static/shared/voting.webp differ
diff --git a/shared/templates/shared/base.html b/shared/templates/shared/base.html
index b89cf70c4bbbc2a574284bf79d6a34dd28c21cfe..0c612109a4a97d224b81f3b88743661302fcb733 100644
--- a/shared/templates/shared/base.html
+++ b/shared/templates/shared/base.html
@@ -39,6 +39,7 @@
         <link rel="shortcut icon" type="image/png" href="https://www.pirati.cz/static/shared/favicon/favicon-32.png" sizes="32x32">
         <link rel="shortcut icon" type="image/png" href="https://www.pirati.cz/static/shared/favicon/favicon-16.png" sizes="16x16">
 
+        {% render_bundle "shared" %}
         {% render_bundle "base" %}
 
         {% block head %}{% endblock %}
diff --git a/shared/templates/shared/index.html b/shared/templates/shared/index.html
index a74d755713145504b354ee941cc6b5ceca9765c4..760351875888695140b3a69a4dc780248a01c464 100644
--- a/shared/templates/shared/index.html
+++ b/shared/templates/shared/index.html
@@ -14,8 +14,8 @@
 {% block content %}
     <main>
         <h1 class="text-6xl font-bebas mb-5">Rychlé nástroje</h1>
-        <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
-           <article class="card">
+        <ul class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
+           <li class="card">
                 <a href="{% url "member_group_size_calc:index" %}">
                     <img
                         src="{% static "shared/calculator.webp" %}"
@@ -33,7 +33,45 @@
                         Výpočet velikosti skupiny členů podle jednacího řádu.
                     </div>
                 </div>
-            </article>
-        </div>
+            </li>
+            <li class="card">
+                <a href="{% url "rv_voting_calc:index" %}">
+                    <img
+                        src="{% static "shared/voting.webp" %}"
+                        alt="Kalkulačka velikosti skupiny členů"
+                        class="w-full h-48 object-cover"
+                    >
+                </a>
+                <div class="p-4">
+                    <h2 class="mb-2 text-xl font-bold">
+                        <a href="{% url "rv_voting_calc:index" %}">
+                            Kalkulačka hlasování RV
+                        </a>
+                    </h2>
+                    <div class="font-light text-sm break-words">
+                        Výpočet velikosti skupiny členů podle jednacího řádu.
+                    </div>
+                </div>
+            </li>
+            <li class="card">
+                <a href="https://z.pirati.cz" target="_blank">
+                    <img
+                        src="{% static "shared/scissors.webp" %}"
+                        alt="Zkracovač odkazů"
+                        class="w-full h-48 object-cover"
+                    >
+                </a>
+                <div class="p-4">
+                    <h2 class="mb-2 text-xl font-bold">
+                        <a href="https://z.pirati.cz" target="_blank">
+                            Zkracovač odkazů
+                        </a>
+                    </h2>
+                    <div class="font-light text-sm break-words">
+                        Webová aplikace sloužící k vytvoření alternativních krátkých URL adres.
+                    </div>
+                </div>
+            </li>
+        </ul>
     </main>
 {% endblock %}
diff --git a/static_src/member_group_size_calc.js b/static_src/member_group_size_calc.js
index 85bf1402d8aafe5adeba5d3f6dccdbaa4a273ca5..be1f821d036ffaaab5e52a701c4bf17e3e3774cf 100644
--- a/static_src/member_group_size_calc.js
+++ b/static_src/member_group_size_calc.js
@@ -1,9 +1,5 @@
 import $ from "jquery";
 
-import "@fortawesome/fontawesome-free/js/fontawesome";
-import "@fortawesome/fontawesome-free/js/solid";
-import "@fortawesome/fontawesome-free/js/regular";
-
 $(window).ready(
     () => {
         $("#member-count").on(
diff --git a/static_src/rv_voting_calc.js b/static_src/rv_voting_calc.js
new file mode 100644
index 0000000000000000000000000000000000000000..828ba0e3e8f8ab7202ca7e1634c103372a4a53b4
--- /dev/null
+++ b/static_src/rv_voting_calc.js
@@ -0,0 +1,135 @@
+import jQuery from "jquery";
+
+Object.assign(window, { $: jQuery, jQuery });
+
+import Cookies from "js-cookie";
+
+require("select2/dist/js/i18n/cs");
+import "select2/dist/js/select2.full";
+import "select2/dist/css/select2.min.css";
+
+$(window).ready(
+    () => {
+        const RV_MEMBERS = JSON.parse($("#rv-members")[0].textContent);
+
+        $(".__vote-selection").select2({
+            tags: true,
+            tokenSeparators: [",", " "],
+            // https://stackoverflow.com/a/28657702 - Thanks to Artur Filipiak!
+            createTag: tag => {
+                return {
+                    id: tag.term,
+                    text: tag.term,
+                    isNew : true
+                };
+            }
+        });
+
+        $(".__vote-selection").on(
+            "select2:selecting",
+            event => {
+                // Prevent empty tags.
+                if (event.params.args.data.id == "") {
+                    event.preventDefault();
+                }
+            }
+        );
+
+        $(".__vote-selection").on(
+            "select2:select",
+            event => {
+                $("#count-votes").prop("disabled", false);
+
+                // Keep user-defined ordering. This is imperative!
+                // http://github.com/select2/select2/issues/3106 - Thanks to kevin-brown!
+                const element = $(event.params.data.element);
+                
+                element.detach();
+                $(this).append(element);
+                $(this).trigger("change");
+            }
+        );
+
+        $(".__vote-selection").on(
+            "select2:unselect",
+            event => {
+                for (const selection of $(".__vote-selection")) {
+                    if ($(selection).select2("data").length !== 0) {
+                        return;
+                    }
+                }
+
+                $("#count-votes").prop("disabled", true);
+            }
+        );
+
+        $("#count-votes").on(
+            "click",
+            async (event) => {
+                $(event.currentTarget).addClass("btn--loading").prop("disabled", true);
+
+                let votes = {};
+
+                for (const username of Object.keys(RV_MEMBERS)) {
+                    const selectedOptions = $(`#${$.escapeSelector(username)}-selection`).select2("data");
+
+                    if (selectedOptions.length === 0) {
+                        continue;
+                    }
+
+                    votes[username] = [];
+
+                    for (const option of selectedOptions) {
+                        votes[username].push(option.id);
+                    }
+                }
+
+                const urlParams = new URL(window.location).searchParams;
+                const rvGid = urlParams.get("rv_gid");
+                const seed = urlParams.get("seed");
+
+                let response = await fetch(
+                    VOTE_CALCULATION_ENDPOINT
+                    + `?votes=${encodeURIComponent(JSON.stringify(votes))}`
+                    + (
+                        (rvGid !== null) ?
+                        `&rv_gid=${encodeURIComponent(rvGid)}` : ""
+                    )
+                    + (
+                        (seed !== null) ?
+                        `&seed=${encodeURIComponent(seed)}` : ""
+                    )
+                );
+
+                if (!response.ok) {
+                    alert("Chyba při získávání výsledků hlasování.");
+                    return;
+                }
+
+                $("#result").html(await response.text());
+
+                $("#permalink,#download-log").show();
+                $("#permalink").attr(
+                    "href",
+                    (
+                        // Base URL
+                        window.location.href.split('?')[0]
+                        + `?votes=${encodeURIComponent(JSON.stringify(votes))}`
+                        + `&rv_gid=${encodeURIComponent(Cookies.get("rv_gid"))}`
+                        + `&seed=${encodeURIComponent(Cookies.get("seed"))}`
+                    )
+                );
+                $("#download-log").attr(
+                    "href",
+                    (
+                        "data:text/plain;charset=utf-8,"
+                        + encodeURIComponent($("#md-steps").html())
+                    )
+                );
+
+                $("#result")[0].scrollIntoView();
+                $(event.currentTarget).removeClass("btn--loading").prop("disabled", false);
+            }
+        );
+    }
+)
diff --git a/static_src/shared/utils.js b/static_src/shared/utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..7a64d68dfe128aaa3d02173ce815da76ac4baf8d
--- /dev/null
+++ b/static_src/shared/utils.js
@@ -0,0 +1,5 @@
+const escapeHTML = (source) => {
+    return new Option(source).innerHTML;
+}
+
+export { escapeHTML };
diff --git a/tailwind.config.js b/tailwind.config.js
index 6d9541ca09b55c509684300c618cf6c106419607..882ff8fc6f21218aac61604077d8e0cdd47ff91e 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,4 +1,4 @@
-const defaultTheme = require('tailwindcss/defaultTheme')
+const defaultTheme = require("tailwindcss/defaultTheme");
 
 /** @type {import('tailwindcss').Config} */
 module.exports = {
diff --git a/webpack.config.js b/webpack.config.js
index bb38e048355512ab66e1552d8d8e44d2ab37c14e..d187e3f50a23b30b2bf9fc4e0ed3ff609aea096f 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -5,8 +5,19 @@ module.exports = {
   mode: "production",
   context: __dirname,
   entry: {
-    base: path.resolve("static_src", "base.js"),
-    member_group_size_calc: path.resolve("static_src", "member_group_size_calc.js"),
+    base: {
+      import: path.resolve("static_src", "base.js"),
+      dependOn: "shared",
+    },
+    member_group_size_calc: {
+      import: path.resolve("static_src", "member_group_size_calc.js"),
+      dependOn: "shared",
+    },
+    rv_voting_calc: {
+      import: path.resolve("static_src", "rv_voting_calc.js"),
+      dependOn: "shared",
+    },
+    shared: ["jquery"],
   },
   output: {
     path: path.resolve(__dirname, "shared", "static", "shared"),
@@ -20,6 +31,9 @@ module.exports = {
       },
     ],
   },
+  optimization: {
+    runtimeChunk: "single",
+  },
   plugins: [
     new BundleTracker({filename: './webpack-stats.json'})
   ],