From b264dcd20ed36300cda6b3e22a69a1f2d751bfec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Valenta?= <git@imaniti.org>
Date: Sun, 22 Oct 2023 23:56:34 +0200
Subject: [PATCH] semi-finish realtime updates

---
 static_src/timer.js                           | 202 ++++++++++++------
 timer/consumers.py                            | 191 +++++++++++------
 ...timer_is_running_ongoingtimer_iteration.py |  23 ++
 timer/models.py                               |   4 +
 timer/routing.py                              |   2 +-
 timer/templates/timer/edit_timer.html         |   9 +-
 timer/templates/timer/view_timer.html         |   2 +
 7 files changed, 307 insertions(+), 126 deletions(-)
 create mode 100644 timer/migrations/0004_ongoingtimer_is_running_ongoingtimer_iteration.py

diff --git a/static_src/timer.js b/static_src/timer.js
index 4729748..9ddf460 100644
--- a/static_src/timer.js
+++ b/static_src/timer.js
@@ -4,6 +4,14 @@ import Timer from "easytimer.js"
 import alertify from "alertifyjs";
 import "alertifyjs/build/css/alertify.css";
 
+const disableInputs = () => {
+    $("#pause_play,#minutes,#seconds,#update_time").prop("disabled", true)
+}
+
+const enableInputs = () => {
+    $("#pause_play,#minutes,#seconds,#update_time").prop("disabled", false)
+}
+
 const updateTimeText = (timer) => {
     const timeValues = timer.getTimeValues()
 
@@ -32,10 +40,47 @@ const assignEventListeners = (timer) => {
     updateTimeText(timer)
 }
 
+const updateTimer = (timer, data, continuePlaying) => {
+    console.info(`Updating timer: ${JSON.stringify(data.sync_time)}`)
+
+    window.startingTime = {
+        minutes: data["sync_time"]["minutes"],
+        seconds: data["sync_time"]["seconds"]
+    }
+
+    timer.removeAllEventListeners()
+
+    timer = new Timer({
+        countdown: true,
+        startValues: {
+            minutes: window.startingTime.minutes,
+            seconds: window.startingTime.seconds,
+        }
+    })
+
+    assignEventListeners(timer)
+
+    if (window.timerIsRunning) {
+        timer.start()
+    }
+
+    return timer
+}
+
+const syncTime = (timerSocket, timer) => {
+    timerSocket.send(JSON.stringify({
+        "sync": timer.getTimeValues()
+    }))
+}
+
 $(window).ready(
     () => {
+        disableInputs()
+
         // --- BEGIN Timer ---
 
+        window.timerIsRunning = false
+
         let timer = new Timer({
             countdown: true,
             startValues: {
@@ -55,6 +100,8 @@ $(window).ready(
                 )
                 + window.location.host
                 + "/ws/timer/"
+                + window.timerId
+                + "/"
             )
 
             if (!isInitialConnect) {
@@ -64,12 +111,27 @@ $(window).ready(
             isInitialConnect = false
 
             timerSocket.onmessage = (event) => {
-                console.log("Received timer message:", event.data)
+                enableInputs()
+
+                console.info("Received timer message:", event.data)
 
                 const data = JSON.parse(event.data)
 
-                if ("status" in data) {
-                    if (data["status"] === "playing") {
+                if ("sync_time" in data) {
+                    // Only update if there is any real difference.
+
+                    const remainingTime = timer.getTimeValues()
+
+                    if (
+                        data.sync_time.minutes !== remainingTime.minutes ||
+                        data.sync_time.seconds !== remainingTime.seconds
+                    ) {
+                        timer = updateTimer(timer, data)
+                    }
+                }
+
+                if ("is_running" in data) {
+                    if (data["is_running"]) {
                         // Reset if we are playing again.
                         const remainingTime = timer.getTimeValues()
 
@@ -86,33 +148,92 @@ $(window).ready(
                         }
 
                         timer.start()
-                    } else if (data["status"] === "paused") {
+
+                        $("#is_counting").prop("checked", true)
+                        $("#pause_play > .btn__body").html("⏸︎")
+                        window.timerIsRunning = true
+                    } else {
                         timer.pause()
+
+                        $("#is_counting").prop("checked", false)
+                        $("#pause_play > .btn__body").html("⏵︎")
+                        window.timerIsRunning = false
                     }
-                } else if ("time" in data) {
-                    timer.pause()
+                }
+            }
+
+            let interval = null
+
+            timerSocket.onopen = () => {
+                syncTime(timerSocket, timer)
+
+                interval = setInterval(syncTime, 5000, timerSocket, timer)
 
-                    window.startingTime = {
-                        minutes: data["time"]["minutes"],
-                        seconds: data["time"]["seconds"]
+                // --- BEGIN Controls ---
+
+                $("#pause_play").on(
+                    "click",
+                    (event) => {
+                        $("#is_counting").click()
                     }
+                )
 
-                    timer = new Timer({
-                        countdown: true,
-                        startValues: {
-                            minutes: window.startingTime.minutes,
-                            seconds: window.startingTime.seconds,
+                $("#is_counting").on(
+                    "change",
+                    (event) => {
+                        disableInputs()
+
+                        if (event.target.checked) {
+                            console.info("Starting timer")
+
+                            $("#pause_play > .btn__body").html("⏸︎")
+                            timerSocket.send(JSON.stringify({
+                                "is_running": true
+                            }))
+                        } else {
+                            console.info("Stopping timer")
+
+                            $("#pause_play > .btn__body").html("⏵︎")
+                            timerSocket.send(JSON.stringify({
+                                "is_running": false
+                            }))
                         }
-                    })
+                    }
+                )
 
-                    assignEventListeners(timer)
-                }
+                $("#update_time").on(
+                    "click",
+                    (event) => {
+                        disableInputs()
+
+                        timer.pause()
+
+                        let minutes = Number($("#minutes").val())
+                        let seconds = Number($("#seconds").val())
+
+                        timerSocket.send(JSON.stringify({
+                            "time": {
+                                "minutes": minutes,
+                                "seconds": seconds
+                            },
+                            "is_running": false
+                        }))
+
+                        $("#is_counting").prop("checked", false)
+                    }
+                )
+
+                // --- END Controls ---
             }
 
             timerSocket.onclose = (event) => {
+                disableInputs()
+
                 alertify.error("Ztráta spojení, pokoušíme se o zpětné připojení.")
 
                 setTimeout(connectToSocket, 5000)
+                clearInterval(interval)
+                $("#is_counting,#pause_play,#update_time").unbind("click")
             }
         }
 
@@ -120,52 +241,5 @@ $(window).ready(
         assignEventListeners(timer)
 
         // --- END Timer ---
-
-        // --- BEGIN Controls ---
-
-        $("#pause_play").on(
-            "click",
-            (event) => {
-                $("#is_counting").click()
-            }
-        )
-
-        $("#is_counting").on(
-            "change",
-            (event) => {
-                if (event.target.checked) {
-                    $("#pause_play > .btn__body").html("⏸︎")
-
-                    timerSocket.send(JSON.stringify({
-                        "status": "playing"
-                    }))
-                } else {
-                    $("#pause_play > .btn__body").html("⏵︎")
-
-                    timerSocket.send(JSON.stringify({
-                        "status": "paused"
-                    }))
-                }
-            }
-        )
-
-        $("#update-time").on(
-            "click",
-            (event) => {
-                let minutes = Number($("#minutes").val())
-                let seconds = Number($("#seconds").val())
-
-                timerSocket.send(JSON.stringify({
-                    "time": {
-                        "minutes": minutes,
-                        "seconds": seconds
-                    }
-                }))
-
-                $("#is_counting").prop("checked", false)
-            }
-        )
-
-        // --- END Controls ---
     }
 )
diff --git a/timer/consumers.py b/timer/consumers.py
index e0a9be6..059dd27 100644
--- a/timer/consumers.py
+++ b/timer/consumers.py
@@ -1,90 +1,161 @@
+import copy
 import datetime
 import json
+import threading
+import time
+from timeit import default_timer
 
-from asgiref.sync import async_to_sync
+from asgiref.sync import sync_to_async
 from channels.generic.websocket import AsyncWebsocketConsumer
-import time
 
+from .models import OngoingTimer
 
-class TimerConsumer(AsyncWebsocketConsumer):
-    timer_is_running = False
+running_timer_threads = []
+
+
+def tick_timer(timer, iteration: int, total_seconds: int) -> None:
+    second_compensation = 0
+
+    start_time = default_timer()
+
+    timer.refresh_from_db()
+
+    end_time = default_timer()
+    second_compensation = end_time - start_time
+
+    while not timer.is_running:
+        time.sleep(0.05)
+
+        timer.refresh_from_db()
+
+        if iteration != timer.iteration:
+            # Stop the thread if there is a new timer
+
+            running_timer_threads.remove(timer.id)
+            return
+
+    start_time = default_timer()
+
+    if iteration != timer.iteration:
+        # Stop the thread if there is a new timer
+
+        running_timer_threads.remove(timer.id)
+        return
+
+    if total_seconds < 1:
+        return
+
+    total_seconds -= 1
 
+    seconds = total_seconds % 60
+    minutes = round((total_seconds - seconds) / 60)
+
+    timer.minutes = minutes
+    timer.seconds = seconds
+
+    timer.save()
+
+    end_time = default_timer()
+    second_compensation += end_time - start_time
+
+    threading.Timer(
+        interval=1 - second_compensation,
+        function=tick_timer,
+        args=(
+            timer,
+            iteration,
+            total_seconds,
+        ),
+    ).start()
+
+
+class TimerConsumer(AsyncWebsocketConsumer):
     async def connect(self) -> None:
-        await self.channel_layer.group_add("timer", self.channel_name)
-        await self.accept()
+        timer_id = self.scope["url_route"]["kwargs"]["id"]
+        timer = await sync_to_async(OngoingTimer.objects.filter(id=timer_id).first)()
 
-    async def disconnect(self, close_code) -> None:
-        await self.channel_layer.group_discard("timer", self.channel_name)
+        if timer is None:
+            # Not found
 
-    async def run_timer(self, minutes: int, seconds: int) -> None:
-        total_seconds = (minutes * 60) + seconds
-        first_run = True
+            await self.close()
+            return
 
-        print("running timer")
+        self.timer = timer
 
-        for second in range(total_seconds + 1):
-            if not first_run:
-                while not self.timer_is_running:
-                    time.sleep(0.05)
+        await self.channel_layer.group_add(str(self.timer.id), self.channel_name)
+        await self.accept()
+
+        if self.timer.is_running and self.timer.id not in running_timer_threads:
+            await self.run_timer()
+
+    async def disconnect(self, close_code) -> None:
+        if hasattr(self, "timer"):
+            await self.channel_layer.group_discard(
+                str(self.timer.id), self.channel_name
+            )
 
-            # Do on the first run, then every 5 seconds
-            if first_run or total_seconds % 5 == 0:
-                new_seconds = total_seconds % 60
-                new_minutes = round((total_seconds - new_seconds) / 60)
+    async def run_timer(self, minutes: int = None, seconds: int = None) -> None:
+        self.timer.iteration += 1
+        await sync_to_async(self.timer.save)()
 
-                print("sending")
+        while self.timer.id in running_timer_threads:
+            # Wait until the thread is freed
+            time.sleep(0.05)
 
-                await self.channel_layer.group_send(
-                    "timer",
-                    {
-                        "type": "timer_message",
-                        "text": json.dumps({
-                            "time": {
-                                "minutes": new_minutes,
-                                "seconds": new_seconds
-                            }
-                        })
-                    }
-                )
+        running_timer_threads.append(self.timer.id)
 
-            if not first_run:
-                total_seconds -= 1
-                time.sleep(1)
+        total_seconds = (self.timer.minutes * 60) + self.timer.seconds
 
-            first_run = False
+        time.sleep(1)
+
+        threading.Thread(
+            target=tick_timer,
+            args=(
+                self.timer,
+                self.timer.iteration,
+                total_seconds,
+            ),
+        ).start()
 
     async def receive(self, text_data: str) -> None:
+        await sync_to_async(self.timer.refresh_from_db)()
+
         json_data = json.loads(text_data)
 
-        response = None
-        time_updated = False
+        response = {}
 
-        if "status" in json_data:
-            if json_data["status"] == "playing":
-                response = {"status": "playing"}
-                self.timer_is_running = True
-            elif json_data["status"] == "paused":
-                response = {"status": "paused"}
-                self.timer_is_running = False
+        reset_timer = False
+
+        if "is_running" in json_data:
+            if json_data["is_running"]:
+                self.timer.is_running = True
+                await sync_to_async(self.timer.save)()
+            else:
+                self.timer.is_running = False
+                await sync_to_async(self.timer.save)()
 
         if "time" in json_data:
-            minutes = json_data["time"]["minutes"]
-            seconds = json_data["time"]["seconds"]
-
-            response = {
-                "time": {
-                    "minutes": minutes,
-                    "seconds": seconds,
-                }
+            self.timer.minutes = json_data["time"]["minutes"]
+            self.timer.seconds = json_data["time"]["seconds"]
+
+            reset_timer = True
+
+        response.update(
+            {
+                "sync_time": {
+                    "minutes": self.timer.minutes,
+                    "seconds": self.timer.seconds,
+                },
+                "is_running": self.timer.is_running,
             }
+        )
 
-            time_updated = True
-
-        if response is not None:
-            if time_updated:
-                await self.run_timer(minutes, seconds)
+        if reset_timer:
+            await self.run_timer()
 
-        await self.send(text_data=json.dumps({}))
+        await self.channel_layer.group_send(
+            str(self.timer.id), {"type": "timer_message", "text": json.dumps(response)}
+        )
 
     async def timer_message(self, event: dict) -> None:
         await self.send(text_data=event["text"])
diff --git a/timer/migrations/0004_ongoingtimer_is_running_ongoingtimer_iteration.py b/timer/migrations/0004_ongoingtimer_is_running_ongoingtimer_iteration.py
new file mode 100644
index 0000000..12bb46d
--- /dev/null
+++ b/timer/migrations/0004_ongoingtimer_is_running_ongoingtimer_iteration.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.1.5 on 2023-10-22 09:05
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('timer', '0003_remove_ongoingtimer_hours'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='ongoingtimer',
+            name='is_running',
+            field=models.BooleanField(default=False, verbose_name='Aktuálně běží'),
+        ),
+        migrations.AddField(
+            model_name='ongoingtimer',
+            name='iteration',
+            field=models.IntegerField(default=0, verbose_name='Iterace'),
+        ),
+    ]
diff --git a/timer/models.py b/timer/models.py
index 545cb28..a715043 100644
--- a/timer/models.py
+++ b/timer/models.py
@@ -30,3 +30,7 @@ class OngoingTimer(models.Model):
             MaxValueValidator(limit_value=60),
         ],
     )
+
+    is_running = models.BooleanField(verbose_name="Aktuálně běží", default=False)
+
+    iteration = models.IntegerField(verbose_name="Iterace", default=0)
diff --git a/timer/routing.py b/timer/routing.py
index 98d01b5..5efd46f 100644
--- a/timer/routing.py
+++ b/timer/routing.py
@@ -3,5 +3,5 @@ from django.urls import path
 from . import consumers
 
 websocket_urlpatterns = [
-    path("ws/timer/", consumers.TimerConsumer.as_asgi()),
+    path("ws/timer/<int:id>/", consumers.TimerConsumer.as_asgi()),
 ]
diff --git a/timer/templates/timer/edit_timer.html b/timer/templates/timer/edit_timer.html
index 370a720..d2f0add 100644
--- a/timer/templates/timer/edit_timer.html
+++ b/timer/templates/timer/edit_timer.html
@@ -20,11 +20,18 @@
             minutes: {{ timer.minutes }},
             seconds: {{ timer.seconds }},
         }
+
+        window.timerId = "{{ timer.id }}"
     </script>
 
     <main>
         <h1 class="text-6xl font-bebas">Ăšprava ÄŤasovaÄŤe {{ timer.name }}</h1>
 
+        <a class="hover:no-underline" href="{% url 'timer:view_timer' timer.id %}">
+            <i class="ico--chevron-left"></i>
+            <span class="underline">Zpět na zobrazení</span>
+        </a>
+
         <hr>
 
         <div class="text-xl">
@@ -75,7 +82,7 @@
                     autocomplete="off"
                 >
                 <button
-                    id="update-time"
+                    id="update_time"
                     class="btn w-64"
                 >
                     <div class="btn__body">Aktualizovat ÄŤas</div>
diff --git a/timer/templates/timer/view_timer.html b/timer/templates/timer/view_timer.html
index 88feba5..c20b679 100644
--- a/timer/templates/timer/view_timer.html
+++ b/timer/templates/timer/view_timer.html
@@ -22,6 +22,8 @@
             minutes: {{ timer.minutes }},
             seconds: {{ timer.seconds }},
         }
+
+        window.timerId = "{{ timer.id }}"
     </script>
 
     <main class="text-center bg-black text-white h-screen flex flex-col justify-between py-5">
-- 
GitLab