diff --git a/Makefile b/Makefile
index 0aa344656622e2bcda54d6f306af6721624dc697..13b1223cfb36e3becb48de43196b7f990bc1b3ec 100644
--- a/Makefile
+++ b/Makefile
@@ -29,11 +29,11 @@ venv: .venv/bin/python
 
 install: venv
 	${VENV}/bin/pip install -r requirements/base.txt -r requirements/production.txt
-	${VENV}/bin/npm install
+	npm install
 
 
 build: venv
-	${VENV}/bin/npm run build
+	npm run build
 	${VENV}/bin/python manage.py collectstatic --noinput --settings=${SETTINGS}
 
 install-hooks:
diff --git a/instagram_token/apps.py b/instagram_token/apps.py
index 135a246c66478b2cdb299307c8b08b1c71354cc1..0b34638f148e695ad5b9c1c82c65d66922acb453 100644
--- a/instagram_token/apps.py
+++ b/instagram_token/apps.py
@@ -2,5 +2,5 @@ from django.apps import AppConfig
 
 
 class InstagramTokenConfig(AppConfig):
-    default_auto_field = 'django.db.models.BigAutoField'
-    name = 'instagram_token'
+    default_auto_field = "django.db.models.BigAutoField"
+    name = "instagram_token"
diff --git a/instagram_token/views.py b/instagram_token/views.py
index 9f31e77dd1e0d1d82e3dc15538956ae68eaefb1e..0adee66debfe8429549d9c373f51284b517fb833 100644
--- a/instagram_token/views.py
+++ b/instagram_token/views.py
@@ -1,5 +1,4 @@
 import requests
-
 from django.conf import settings
 from django.shortcuts import render
 from django.urls import reverse
@@ -20,11 +19,7 @@ def index(request):
     )
 
     return render(
-        request,
-        "instagram_token/index.html",
-        {
-            "authorization_url": authorization_url
-        }
+        request, "instagram_token/index.html", {"authorization_url": authorization_url}
     )
 
 
@@ -41,8 +36,10 @@ def exchange(request):
             "client_secret": settings.INSTAGRAM_CLIENT_SECRET,
             "code": code,
             "grant_type": "authorization_code",
-            "redirect_uri": request.build_absolute_uri(reverse("instagram_token:exchange")),
-        }
+            "redirect_uri": request.build_absolute_uri(
+                reverse("instagram_token:exchange")
+            ),
+        },
     )
 
     if not exchange_request.ok:
@@ -56,5 +53,5 @@ def exchange(request):
         {
             "access_token": exchange_request["access_token"],
             "user_id": exchange_request["user_id"],
-        }
+        },
     )
diff --git a/package-lock.json b/package-lock.json
index 1ffe84608e5db3d89a6ec42a46c5e2634989b1b0..779f79f3699d4a7d226535f3984d4b2926dffb4e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
         "@tailwindcss/typography": "^0.4.1",
         "alertifyjs": "^1.13.1",
         "css-loader": "^6.7.3",
+        "easytimer.js": "^4.5.4",
         "jquery": "^3.6.4",
         "js-cookie": "^3.0.1",
         "select2": "^4.1.0-rc.0",
@@ -933,6 +934,11 @@
       "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
       "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
     },
+    "node_modules/easytimer.js": {
+      "version": "4.5.4",
+      "resolved": "https://registry.npmjs.org/easytimer.js/-/easytimer.js-4.5.4.tgz",
+      "integrity": "sha512-65STy2sW2z6e9XwfJqSa18JVNWuOu2cb/FXaZ/BbiDiPnTvC53njMS3oY1BsAIm/Dzt9c8YUvcgc8FoPttm1Gw=="
+    },
     "node_modules/electron-to-chromium": {
       "version": "1.4.284",
       "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz",
@@ -2198,9 +2204,9 @@
       "integrity": "sha512-Hr9TdhyHCZUtwznEH2CBf7967mEM0idtJ5nMtjvk3Up5tPukOLXbHUNmh10oRfeNIhj+3GD3niu+g6sVK+gK0A=="
     },
     "node_modules/semver": {
-      "version": "7.3.8",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
-      "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
+      "version": "7.5.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
+      "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
       "dependencies": {
         "lru-cache": "^6.0.0"
       },
diff --git a/package.json b/package.json
index a372dd615e06a6bd4475e6b508f24d3e195023bb..8652882bcb0341d72241def4c324a6176d624262 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
     "@tailwindcss/typography": "^0.4.1",
     "alertifyjs": "^1.13.1",
     "css-loader": "^6.7.3",
+    "easytimer.js": "^4.5.4",
     "jquery": "^3.6.4",
     "js-cookie": "^3.0.1",
     "select2": "^4.1.0-rc.0",
diff --git a/requirements/base.txt b/requirements/base.txt
index 287bf10146a2e892c511e89d51f443ee0c7ffce0..c696c595f69d5c647056a3adb3e217d48a96cf5f 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -1,3 +1,4 @@
+channels[daphne]==4.0.0
 Django==4.1.5
 django-database-url==1.0.3
 django-environ==0.9.0
diff --git a/requirements/production.txt b/requirements/production.txt
index ce5169e4440b67843ee5d28199ed63e0d8323cfc..088e218c836756413516b33d6f4aaffc0971ef33 100644
--- a/requirements/production.txt
+++ b/requirements/production.txt
@@ -1,2 +1,3 @@
-gunicorn==20.1.0
 whitenoise==6.3.0
+uvicorn==0.23.2
+gunicorn==21.2.0
diff --git a/run.sh b/run.sh
index 5d9a7ada6bf4106d23769d523da59ae1e68e3720..402790eba0a74f9db9121b8607f176a225e70638 100644
--- a/run.sh
+++ b/run.sh
@@ -7,4 +7,4 @@ set -e
 python manage.py migrate
 
 # start webserver
-exec gunicorn -c gunicorn.conf.py rybicka.wsgi
+exec gunicorn -c gunicorn.conf.py rybicka.asgi:application -k uvicorn.workers.UvicornWorker
diff --git a/rybicka/asgi.py b/rybicka/asgi.py
index 851133e205c80ad01650d5120324f179fcb15f90..59bb6e0abd2b1719eb44521b9e544101b7c45b5e 100644
--- a/rybicka/asgi.py
+++ b/rybicka/asgi.py
@@ -1,16 +1,22 @@
-"""
-ASGI config for rybicka project.
-
-It exposes the ASGI callable as a module-level variable named ``application``.
-
-For more information on this file, see
-https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/
-"""
-
 import os
 
+from channels.auth import AuthMiddlewareStack
+from channels.routing import ProtocolTypeRouter, URLRouter
+from channels.security.websocket import AllowedHostsOriginValidator
 from django.core.asgi import get_asgi_application
 
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rybicka.settings")
+from timer.routing import websocket_urlpatterns
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rybicka.settings.production")
+# Initialize Django ASGI application early to ensure the AppRegistry
+# is populated before importing code that may import ORM models.
+django_asgi_app = get_asgi_application()
 
-application = get_asgi_application()
+application = ProtocolTypeRouter(
+    {
+        "http": django_asgi_app,
+        "websocket": AllowedHostsOriginValidator(
+            AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
+        ),
+    }
+)
diff --git a/rybicka/settings/base.py b/rybicka/settings/base.py
index fc2d8af8fd05389189842e1926b717c2e84e5e69..0fe4966f7b484930b42a8c5186c5343cf0cf4f94 100644
--- a/rybicka/settings/base.py
+++ b/rybicka/settings/base.py
@@ -42,8 +42,11 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
 # Application definition
 
 INSTALLED_APPS = [
+    "daphne",
+    "django.contrib.auth",
     "django.contrib.contenttypes",
     "django.contrib.sessions",
+    "django.contrib.sites",
     "django.contrib.messages",
     "django.contrib.staticfiles",
     "webpack_loader",
@@ -51,6 +54,7 @@ INSTALLED_APPS = [
     "member_group_size_calc",
     "rv_voting_calc",
     "mail_signature",
+    "timer",
     "asset_server_resize",
 ]
 
@@ -82,7 +86,7 @@ TEMPLATES = [
     },
 ]
 
-WSGI_APPLICATION = "rybicka.wsgi.application"
+ASGI_APPLICATION = "rybicka.asgi.application"
 
 
 # Database
@@ -124,3 +128,6 @@ CHOBOTNICE_API_URL = env.str(
     "CHOBOTNICE_API_URL", "https://chobotnice.pirati.cz/graphql/"
 )
 CHOBOTNICE_RV_GID = env.str("CHOBOTNICE_RV_GID")
+
+# FIXME
+CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}}
diff --git a/rybicka/urls.py b/rybicka/urls.py
index 1ddf91e069fa46fac44d2a45a5cdec2239df044f..634412f8340996778355ac3b9f22a420639e77af 100644
--- a/rybicka/urls.py
+++ b/rybicka/urls.py
@@ -19,6 +19,7 @@ urlpatterns = [
     path("vypocet-skupiny-clenu/", include("member_group_size_calc.urls")),
     path("hlasovani-rv/", include("rv_voting_calc.urls")),
     path("emailove-podpisy/", include("mail_signature.urls")),
+    path("casovace/", include("timer.urls")),
     path("asset-server/", include("asset_server_resize.urls")),
     path("", include("shared.urls")),
 ]
diff --git a/shared/templates/shared/base.html b/shared/templates/shared/base.html
index 47bb0c82724e93f5478c6ee079a58d462015e4a0..fafea799b6a4c9b7c6b21dba89cce8e9945053bb 100644
--- a/shared/templates/shared/base.html
+++ b/shared/templates/shared/base.html
@@ -45,40 +45,44 @@
         {% block head %}{% endblock %}
     </head>
     <body>
-        <nav class="navbar navbar--simple __js-root">
-            <ui-app inline-template>
-                <ui-navbar inline-template>
-                    <div>
-                        <div class="container container--default navbar__content navbar__content--initialized">
-                            <div class="navbar__brand flex items-center pr-8 my-4 lg:my-0">
-                                <a href="{% url "shared:index" %}">
-                                    <img src="https://styleguide.pirati.cz/2.3.x/images/logo-round-white.svg" class="w-8">
-                                </a>
-                                <div class="pl-4 font-bold text-xl border-r border-grey-300 pr-8">
-                                    <a href="{% url "shared:index" %}">RybiÄŤka</a>
+        {% block nav %}
+            <nav class="navbar navbar--simple __js-root">
+                <ui-app inline-template>
+                    <ui-navbar inline-template>
+                        <div>
+                            <div class="container container--default navbar__content navbar__content--initialized">
+                                <div class="navbar__brand flex items-center pr-8 my-4 lg:my-0">
+                                    <a href="{% url "shared:index" %}">
+                                        <img src="https://styleguide.pirati.cz/2.3.x/images/logo-round-white.svg" class="w-8">
+                                    </a>
+                                    <div class="pl-4 font-bold text-xl border-r border-grey-300 pr-8">
+                                        <a href="{% url "shared:index" %}">RybiÄŤka</a>
+                                    </div>
                                 </div>
+                                {% block header_name %}{% endblock %}
                             </div>
-                            {% block header_name %}{% endblock %}
                         </div>
-                    </div>
-                </ui-navbar>
-            </ui-app>
-        </nav>
+                    </ui-navbar>
+                </ui-app>
+            </nav>
+        {% endblock %}
         <div class="container container--default py-8 lg:py-24">
             {% block content %}{% endblock %}
         </div>
-        <footer class="footer bg-grey-700 text-white __js-root hidden lg:block">
-            <ui-app inline-template>
-                <div>
-                    <div class="footer__main py-4 lg:py-16 container container--default">
-                        <section class="footer__brand">
-                            <p class="para text-grey-200">
-                                <span class="copyleft inline-block">&copy;</span> {% now "Y" %} Piráti. Všechna práva vyhlazena. Sdílejte a nechte ostatní sdílet za stejných podmínek.
-                            </p>
-                        </section>
+        {% block footer %}
+            <footer class="footer bg-grey-700 text-white __js-root hidden lg:block">
+                <ui-app inline-template>
+                    <div>
+                        <div class="footer__main py-4 lg:py-16 container container--default">
+                            <section class="footer__brand">
+                                <p class="para text-grey-200">
+                                    <span class="copyleft inline-block">&copy;</span> {% now "Y" %} Piráti. Všechna práva vyhlazena. Sdílejte a nechte ostatní sdílet za stejných podmínek.
+                                </p>
+                            </section>
+                        </div>
                     </div>
-                </div>
-            </ui-app>
-        </footer>
+                </ui-app>
+            </footer>
+        {% endblock %}
     </body>
 </html>
diff --git a/static_src/timer.js b/static_src/timer.js
new file mode 100644
index 0000000000000000000000000000000000000000..6717ea4332a8d1adc8cc11452a26c502cc9f3086
--- /dev/null
+++ b/static_src/timer.js
@@ -0,0 +1,125 @@
+import $ from "jquery"
+import Timer from "easytimer.js"
+
+const assignEventListeners = (timer) => {
+    timer.addEventListener(
+        'secondsUpdated',
+        (event) => {
+            $('#timer .timer-values').html(timer.getTimeValues().toString())
+        }
+    )
+
+    timer.addEventListener(
+        'targetAchieved',
+        (event) => {
+            $("#is_counting").prop("checked", false)
+            $('#timer .timer-values').html("Konec")
+        }
+    )
+
+    $('#timer .timer-values').html(timer.getTimeValues().toString())
+}
+
+$(window).ready(
+    () => {
+        // --- BEGIN Timer ---
+
+        let timer = new Timer({
+            countdown: true,
+            startValues: {
+                hours: window.startingTime.hours,
+                minutes: window.startingTime.minutes,
+                seconds: window.startingTime.seconds,
+            }
+        })
+
+        const timerSocket = new WebSocket(
+            "ws://"
+            + window.location.host
+            + "/ws/timer/"
+        )
+
+        timerSocket.onmessage = (event) => {
+            const data = JSON.parse(event.data)
+
+            if ("status" in data) {
+                if (data["status"] === "playing") {
+                    // Reset if we are playing again.
+                    if (!timer.isRunning()) {
+                        timer = new Timer({
+                            countdown: true,
+                            startValues: {
+                                hours: window.startingTime.hours,
+                                minutes: window.startingTime.minutes,
+                                seconds: window.startingTime.seconds,
+                            }
+                        })
+
+                        assignEventListeners(timer)
+                    }
+
+                    timer.start()
+                } else if (data["status"] === "paused") {
+                    timer.pause()
+                }
+            } else if ("time" in data) {
+                timer.pause()
+
+                window.startingTime = {
+                    hours: data["time"]["hours"],
+                    minutes: data["time"]["minutes"],
+                    seconds: data["time"]["seconds"]
+                }
+
+                timer = new Timer({
+                    countdown: true,
+                    startValues: {
+                        hours: window.startingTime.hours,
+                        minutes: window.startingTime.minutes,
+                        seconds: window.startingTime.seconds,
+                    }
+                })
+
+                assignEventListeners(timer)
+            }
+        }
+
+        assignEventListeners(timer)
+
+        // --- END Timer ---
+
+        // --- BEGIN Controls ---
+
+        $("#is_counting").on(
+            "change",
+            (event) => {
+                if (event.target.checked) {
+                    timerSocket.send(JSON.stringify({
+                        "status": "playing"
+                    }))
+                } else {
+                    timerSocket.send(JSON.stringify({
+                        "status": "paused"
+                    }))
+                }
+            }
+        )
+
+        $("#update-time").on(
+            "click",
+            (event) => {
+                timerSocket.send(JSON.stringify({
+                    "time": {
+                        "hours": Number($("#hours").val()),
+                        "minutes": Number($("#minutes").val()),
+                        "seconds": Number($("#seconds").val())
+                    }
+                }))
+
+                $("#is_counting").prop("checked", false)
+            }
+        )
+
+        // --- END Controls ---
+    }
+)
diff --git a/tailwind.config.js b/tailwind.config.js
index 689576f4a063f1736155aff77c3d3d3f48b9738c..e2101ddc0dc9acc1cce8e705d89c4aea9d585f8f 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -3,8 +3,7 @@ const defaultTheme = require("tailwindcss/defaultTheme");
 /** @type {import('tailwindcss').Config} */
 module.exports = {
   content: [
-    "*/templates/*/*.html",
-    "*/templates/*/*/*.html",
+    "*/templates/**/*.html",
   ],
   theme: {
     extend: {
diff --git a/timer/__init__.py b/timer/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/timer/admin.py b/timer/admin.py
new file mode 100644
index 0000000000000000000000000000000000000000..8c38f3f3dad51e4585f3984282c2a4bec5349c1e
--- /dev/null
+++ b/timer/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/timer/apps.py b/timer/apps.py
new file mode 100644
index 0000000000000000000000000000000000000000..2760d843bca5fc9a0b893942a598eba7aeae8929
--- /dev/null
+++ b/timer/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class TimerConfig(AppConfig):
+    default_auto_field = "django.db.models.BigAutoField"
+    name = "timer"
diff --git a/timer/consumers.py b/timer/consumers.py
new file mode 100644
index 0000000000000000000000000000000000000000..1946e2ff37b88f38c2b2e812820ed687e84f88e7
--- /dev/null
+++ b/timer/consumers.py
@@ -0,0 +1,44 @@
+import json
+
+from asgiref.sync import async_to_sync
+from channels.generic.websocket import WebsocketConsumer
+
+
+class TimerConsumer(WebsocketConsumer):
+    def connect(self):
+        async_to_sync(self.channel_layer.group_add)("timer", self.channel_name)
+
+        self.accept()
+
+    def disconnect(self, close_code):
+        pass
+
+    def receive(self, text_data):
+        json_data = json.loads(text_data)
+
+        response = None
+
+        if "status" in json_data:
+            if json_data["status"] == "playing":
+                response = {"status": "playing"}
+            elif json_data["status"] == "paused":
+                response = {"status": "paused"}
+
+        if "time" in json_data:
+            response = {
+                "time": {
+                    "hours": json_data["time"]["hours"],
+                    "minutes": json_data["time"]["minutes"],
+                    "seconds": json_data["time"]["seconds"],
+                }
+            }
+
+        if response is not None:
+            async_to_sync(self.channel_layer.group_send)(
+                "timer", {"type": "timer_message", "text": json.dumps(response)}
+            )
+
+        self.send(text_data=json.dumps({}))
+
+    def timer_message(self, event):
+        self.send(text_data=event["text"])
diff --git a/timer/forms.py b/timer/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..7214ca8397053ef9e748bb39ef0435b641f731fa
--- /dev/null
+++ b/timer/forms.py
@@ -0,0 +1,34 @@
+from django import forms
+from django.core.validators import (
+    MaxValueValidator,
+    MinLengthValidator,
+    MinValueValidator,
+)
+
+
+class TimeOnlyForm(forms.Form):
+    hours = forms.IntegerField(
+        label="Hodiny", validators=[MinValueValidator(limit_value=0)]
+    )
+
+    minutes = forms.IntegerField(
+        label="Minuty",
+        validators=[
+            MinValueValidator(limit_value=0),
+            MaxValueValidator(limit_value=60),
+        ],
+    )
+
+    seconds = forms.IntegerField(
+        label="Minuty",
+        validators=[
+            MinValueValidator(limit_value=0),
+            MaxValueValidator(limit_value=60),
+        ],
+    )
+
+
+class NewTimerForm(TimeOnlyForm):
+    name = forms.CharField(
+        label="Název", max_length=64, validators=[MinLengthValidator(limit_value=1)]
+    )
diff --git a/timer/migrations/0001_initial.py b/timer/migrations/0001_initial.py
new file mode 100644
index 0000000000000000000000000000000000000000..93e00c45a863e2f6e2f6cb051a5f591145e0c179
--- /dev/null
+++ b/timer/migrations/0001_initial.py
@@ -0,0 +1,57 @@
+# Generated by Django 4.1.5 on 2023-08-24 13:11
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    initial = True
+
+    dependencies = []
+
+    operations = [
+        migrations.CreateModel(
+            name="OngoingTimer",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("name", models.CharField(max_length=64, verbose_name="Název")),
+                (
+                    "hours",
+                    models.IntegerField(
+                        validators=[
+                            django.core.validators.MinValueValidator(limit_value=0)
+                        ],
+                        verbose_name="Hodiny",
+                    ),
+                ),
+                (
+                    "minutes",
+                    models.IntegerField(
+                        validators=[
+                            django.core.validators.MinValueValidator(limit_value=0),
+                            django.core.validators.MaxValueValidator(limit_value=60),
+                        ],
+                        verbose_name="Minuty",
+                    ),
+                ),
+                (
+                    "seconds",
+                    models.IntegerField(
+                        validators=[
+                            django.core.validators.MinValueValidator(limit_value=0),
+                            django.core.validators.MaxValueValidator(limit_value=60),
+                        ],
+                        verbose_name="Minuty",
+                    ),
+                ),
+            ],
+        ),
+    ]
diff --git a/timer/migrations/__init__.py b/timer/migrations/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/timer/models.py b/timer/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..cc97e9bb807c624d8c00d923b4275650d8dcee5f
--- /dev/null
+++ b/timer/models.py
@@ -0,0 +1,36 @@
+from django.core.validators import (
+    MaxValueValidator,
+    MinLengthValidator,
+    MinValueValidator,
+)
+from django.db import models
+
+# Create your models here.
+
+
+class OngoingTimer(models.Model):
+    name = models.CharField(
+        max_length=64,
+        verbose_name="Název",
+        validators=[MinLengthValidator(limit_value=1)],
+    )
+
+    hours = models.IntegerField(
+        verbose_name="Hodiny", validators=[MinValueValidator(limit_value=0)]
+    )
+
+    minutes = models.IntegerField(
+        verbose_name="Minuty",
+        validators=[
+            MinValueValidator(limit_value=0),
+            MaxValueValidator(limit_value=60),
+        ],
+    )
+
+    seconds = models.IntegerField(
+        verbose_name="Minuty",
+        validators=[
+            MinValueValidator(limit_value=0),
+            MaxValueValidator(limit_value=60),
+        ],
+    )
diff --git a/timer/routing.py b/timer/routing.py
new file mode 100644
index 0000000000000000000000000000000000000000..98d01b5cabbbee6d586bcf7c3bb89e664cef72f3
--- /dev/null
+++ b/timer/routing.py
@@ -0,0 +1,7 @@
+from django.urls import path
+
+from . import consumers
+
+websocket_urlpatterns = [
+    path("ws/timer/", consumers.TimerConsumer.as_asgi()),
+]
diff --git a/timer/templates/timer/create.html b/timer/templates/timer/create.html
new file mode 100644
index 0000000000000000000000000000000000000000..ec2d87b238d2211f13c26f7a6f396df26c6178d2
--- /dev/null
+++ b/timer/templates/timer/create.html
@@ -0,0 +1,37 @@
+{% extends "shared/base.html" %}
+
+{% load render_bundle from webpack_loader %}
+
+{% block title %}NovĂ˝ ÄŤasovaÄŤ{% endblock %}
+{% block header_name %}ÄŚasovaÄŤe{% endblock %}
+{% block description %}{% endblock %}
+
+{% block head %}
+    <link
+        rel="stylesheet"
+        href="https://styleguide.pirati.cz/2.12.x/css/styles.css"
+    >
+{% endblock %}
+
+{% block content %}
+    <main>
+        <h1 class="text-6xl font-bebas mb-5">NovĂ˝ ÄŤasovaÄŤ</h1>
+
+        <form
+            class="
+                flex flex-col gap-1
+                [&_label]:w-24
+                [&_input]:bg-gray-100 [&_input]:border [&_input]:border-gray-100 [&_input]:px-1.5 [&_input]:py-0.5
+            "
+            method="post"
+        >
+            {% csrf_token %}
+
+            {{ form.as_div }}
+
+            <button class="btn">
+                <div class="btn__body">Vytvořit</div>
+            </button>
+        </form>
+    </main>
+{% endblock %}
diff --git a/timer/templates/timer/edit_timer.html b/timer/templates/timer/edit_timer.html
new file mode 100644
index 0000000000000000000000000000000000000000..d4d75af1639d6c14bd26385bfa9dde5a2e13ace6
--- /dev/null
+++ b/timer/templates/timer/edit_timer.html
@@ -0,0 +1,76 @@
+{% extends "shared/base.html" %}
+
+{% load render_bundle from webpack_loader %}
+
+{% block title %}ÄŚasovaÄŤe{% endblock %}
+{% block header_name %}ÄŚasovaÄŤe{% endblock %}
+{% block description %}{% endblock %}
+
+{% block head %}
+    <link
+        rel="stylesheet"
+        href="https://styleguide.pirati.cz/2.12.x/css/styles.css"
+    >
+    {% render_bundle "timer" %}
+{% endblock %}
+
+{% block content %}
+    <script>
+        window.startingTime = {
+            hours: {{ timer.hours }},
+            minutes: {{ timer.minutes }},
+            seconds: {{ timer.seconds }},
+        }
+    </script>
+
+    <main>
+        <h1 class="text-6xl font-bebas mb">Ăšprava ÄŤasovaÄŤe {{ timer.name }}</h1>
+
+        <div>
+            <div id="timer">
+                <div class="timer-values">{{ timer.hours }}:{{ timer.minutes }}:{{ timer.seconds }}</div>
+            </div>
+        </div>
+
+        <div class="flex flex-col gap-2">
+            <div>
+                <input
+                    type="checkbox"
+                    id="is_counting"
+                    name="is_counting"
+                    autocomplete="off"
+                    value="false"
+                >
+                <label
+                    for="is_counting"
+                >Odpočet spuštěný</label>
+            </div>
+            <div class="flex gap-2">
+                <input
+                    type="number"
+                    id="hours"
+                    name="hours"
+                    placeholder="Hodiny"
+                    autocomplete="off"
+                >
+                <input
+                    type="number"
+                    id="minutes"
+                    name="minutes"
+                    placeholder="Minuty"
+                    autocomplete="off"
+                >
+                <input
+                    type="number"
+                    id="seconds"
+                    name="seconds"
+                    placeholder="Sekundy"
+                    autocomplete="off"
+                >
+                <button
+                    id="update-time"
+                >Aktualizovat ÄŤas</button>
+            </div>
+        </div>
+    </main>
+{% endblock %}
diff --git a/timer/templates/timer/index.html b/timer/templates/timer/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..dec7857d7c85e0518f325e36d96c81fd43101399
--- /dev/null
+++ b/timer/templates/timer/index.html
@@ -0,0 +1,42 @@
+{% extends "shared/base.html" %}
+
+{% load render_bundle from webpack_loader %}
+
+{% block title %}ÄŚasovaÄŤe{% endblock %}
+{% block header_name %}ÄŚasovaÄŤe{% endblock %}
+{% block description %}{% endblock %}
+
+{% block head %}
+    <link
+        rel="stylesheet"
+        href="https://styleguide.pirati.cz/2.12.x/css/styles.css"
+    >
+{% endblock %}
+
+{% block content %}
+    <main>
+        <h1 class="text-6xl font-bebas mb">ÄŚasovaÄŤe</h1>
+        <h2 class="text-3xl font-bebas mb-5">Seznam aktivnĂ­ch ÄŤasovaÄŤĹŻ</h2>
+
+        <div class="prose max-w-none mb-8">
+            <ul>
+                {% for timer in ongoing_timers %}
+                    <li>
+                        <a
+                            href="{% url 'timer:view_timer' timer.id %}"
+                        >{{ timer.name }}</a>
+                    </li>
+                {% endfor %}
+            </ul>
+        </div>
+
+        <a class="btn btn--icon" href="{% url 'timer:create' %}">
+            <div class="btn__body-wrap">
+                <div class="btn__body">NovĂ˝ ÄŤasovaÄŤ</div>
+                <div class="btn__icon">
+                    <i class="ico--chevron-right"></i>
+                </div>
+            </div>
+        </a>
+    </main>
+{% endblock %}
diff --git a/timer/templates/timer/view_timer.html b/timer/templates/timer/view_timer.html
new file mode 100644
index 0000000000000000000000000000000000000000..5e11cf3f617a642f2af0f6a206b4758e7e398ced
--- /dev/null
+++ b/timer/templates/timer/view_timer.html
@@ -0,0 +1,47 @@
+{% extends "shared/base.html" %}
+
+{% load render_bundle from webpack_loader %}
+
+{% block title %}ÄŚasovaÄŤe{% endblock %}
+{% block header_name %}ÄŚasovaÄŤe{% endblock %}
+{% block description %}{% endblock %}
+
+{% block head %}
+    <link
+        rel="stylesheet"
+        href="https://styleguide.pirati.cz/2.12.x/css/styles.css"
+    >
+    {% render_bundle "timer" %}
+{% endblock %}
+
+{% block nav %}{% endblock %}
+
+{% block content %}
+    <script>
+        window.startingTime = {
+            hours: {{ timer.hours }},
+            minutes: {{ timer.minutes }},
+            seconds: {{ timer.seconds }},
+        }
+    </script>
+
+    <main>
+        <h1 class="text-6xl font-bebas mb">{{ timer.name }}</h1>
+
+        <div
+            class="my-5"
+            style="font-size:15rem" {% comment %}TODO{% endcomment %}
+        >
+            <div id="timer">
+                <div class="timer-values">{{ timer.hours }}:{{ timer.minutes }}:{{ timer.seconds }}</div>
+            </div>
+        </div>
+
+        <a
+            class="text-gray-400"
+            href="{% url 'timer:edit_timer' timer.id %}"
+        >Upravit</a>
+    </main>
+{% endblock %}
+
+{% block footer %}{% endblock %}
diff --git a/timer/tests.py b/timer/tests.py
new file mode 100644
index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6
--- /dev/null
+++ b/timer/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/timer/urls.py b/timer/urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..7dc44356fc70ccd72dc4dcdba76f3204e79fe8c9
--- /dev/null
+++ b/timer/urls.py
@@ -0,0 +1,11 @@
+from django.urls import path
+
+from . import views
+
+app_name = "timer"
+urlpatterns = [
+    path("", views.index, name="index"),
+    path("novy", views.create, name="create"),
+    path("<int:id>", views.view_timer, name="view_timer"),
+    path("<int:id>/uprava", views.edit_timer, name="edit_timer"),
+]
diff --git a/timer/views.py b/timer/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..614acc4f8a0230fbd05ed8709c6a6da1253ba5df
--- /dev/null
+++ b/timer/views.py
@@ -0,0 +1,46 @@
+from django.shortcuts import get_object_or_404, redirect, render
+
+from .forms import NewTimerForm, TimeOnlyForm
+from .models import OngoingTimer
+
+
+def index(request):
+    return render(
+        request,
+        "timer/index.html",
+        {
+            "ongoing_timers": OngoingTimer.objects.all(),
+        },
+    )
+
+
+def create(request):
+    if request.method == "POST":
+        form = NewTimerForm(request.POST)
+
+        if form.is_valid():
+            timer = OngoingTimer(
+                hours=form.cleaned_data["hours"],
+                minutes=form.cleaned_data["minutes"],
+                seconds=form.cleaned_data["seconds"],
+                name=form.cleaned_data["name"],
+            )
+            timer.save()
+
+            return redirect("timer:view_timer", id=timer.id)
+    else:
+        form = NewTimerForm()
+
+    return render(request, "timer/create.html", {"form": form})
+
+
+def view_timer(request, id: int):
+    timer = get_object_or_404(OngoingTimer, id=id)
+
+    return render(request, "timer/view_timer.html", {"timer": timer})
+
+
+def edit_timer(request, id: int):
+    timer = get_object_or_404(OngoingTimer, id=id)
+
+    return render(request, "timer/edit_timer.html", {"timer": timer})
diff --git a/webpack.config.js b/webpack.config.js
index 64386ccabf56b6fcb90831314851d5ee99c0b244..6e3af748fb555b9e8a168d13c4d8a731a4862f46 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -21,6 +21,10 @@ module.exports = {
       import: path.resolve("static_src", "mail_signature.js"),
       dependOn: "shared",
     },
+    timer: {
+      import: path.resolve("static_src", "timer.js"),
+      dependOn: "shared",
+    },
     asset_server_resize: {
       import: path.resolve("static_src", "asset_server_resize.js"),
       dependOn: "shared",