diff --git a/countdown/api.py b/countdown/api.py index 3980dfe..365f81a 100644 --- a/countdown/api.py +++ b/countdown/api.py @@ -6,26 +6,42 @@ import uuid import struct from . import app - -db = Walrus(host='localhost', port=6379, db=0) +from .countdown import Cache @app.route('/api/v1/', methods=['GET']) def get_countdown(id): - ct = db.Hash(str(id)) + cache = Cache.getInstance() + countdown = cache.get_countdown(id) - resp = ct.as_dict(decode=True) - resp['left'] = float(ct['total']) - (time() - float(ct['start'])) + response = countdown - return resp + time_passed = time() - float(countdown['start']) + time_left = float(countdown['total']) - time_passed + response['left'] = time_left + response['roundtrip_start'] = request.args.get('roundtrip_start') + + return response @app.route('/api/v1', methods=['POST']) def create_countdown(): + cache = Cache.getInstance() + countdown = request.json - ct_id = str(uuid.uuid4()) - ct = db.Hash(ct_id) - ct.update(start=time(), total=countdown['total']) + total = float(countdown['total']) + response = cache.add_countdown(total) + return response - resp = ct.as_dict(decode=True) - resp['id'] = ct_id +@app.route('/api/v1/start/', methods=['PATCH']) +def start_countdown(id): + cache = Cache.getInstance() + return cache.start_countdown(id) - return resp +@app.route('/api/v1/reset/', methods=['PATCH']) +def reset_countdown(id): + cache = Cache.getInstance() + return cache.reset_countdown(id) + +@app.route('/api/v1/stop/', methods=['PATCH']) +def stop_countdown(id): + cache = Cache.getInstance() + return cache.stop_countdown(id) diff --git a/countdown/countdown.js b/countdown/countdown.js index ed75a26..c7a07f2 100644 --- a/countdown/countdown.js +++ b/countdown/countdown.js @@ -4,45 +4,105 @@ import log from 'loglevel'; import $ from 'jquery'; +import {sleep} from '../js/netclock.js'; + import './countdown.scss'; let api_base = "/countdown/api/v1/"; +let padTime = (t) => { + let t_s = t.toString(); + // Pads to at least two digits filling with 0 + let leftPad = t_s.length < 2 ? + "0".repeat(2 - t.toString().length) : + ""; -const updateCountdown = () => $.getJSON({ - url: api_base+countdown_id, - data: Date.now(), - success: function(countdown) { - if(countdown.left <= 0) { - // clear interval, don't count below 0 - clearInterval(scheduledUpdater); - $("#countdown").text("Time is up!"); - let end = new Date(Math.floor(countdown.start*1000 + countdown.total*1000)); - $("#subtext").text("This countdown ended on "+ end.toLocaleDateString()+" "+end.toLocaleTimeString()); - return; - } + return `${leftPad+t_s}`; +}; - // total seconds left - let sec = Math.floor(countdown.left); - // milliseconds left in seconds resolution - let frac = countdown.left - sec; - // milliseconds left in millisecond resolution - let milli = Math.floor(frac * 1000); - // seconds in hrs, minutes, seconds - let h = Math.floor(sec/(60*60)); - let m = Math.floor(sec/60) - (h*60); - let s = sec - (m*60) - (h*60*60); +let formatTime = (h, m, s) => { + let htext = padTime(h); + let mtext = padTime(m); + let stext = padTime(s); - // let milliseconds pass, then set element to - // amount of full seconds left - let htext = padTime(h); - let mtext = padTime(m); - htext = htext !== "00" ? htext+":": ""; - mtext = mtext !== "00" ? mtext+":": ""; - setTimeout(() => $("#countdown").text(htext+mtext+padTime(s)), milli); + htext = htext !== "00" ? htext + ":" : ""; + mtext = mtext !== "00" || htext !== "00" ? mtext + ":" : ""; + + return htext + mtext + stext; +}; + +let splitTimestamp = (t) => { + // Timestamp as full seconds + let seconds = Math.floor(t); + + return { + "hours": ~~(seconds / 3600), + "minutes": ~~((seconds % 3600) / 60), + "seconds": ~~(seconds % 60), + "milliseconds": ~~((t % 1) * 1000) + }; +}; + +// amount of seconds since last sync +let unsyncedSeconds = 0; +let countdown = undefined; + +let updateCountdown = () => { + if(!countdown) return; + + // Not started + if (countdown.start === "-1") { + let split = splitTimestamp(countdown.total); + $("#countdown").text(formatTime(split.hours, split.minutes, split.seconds)); + $("#subtext").text("Countdown has not started"); + return; } -}); -let scheduledUpdater = setInterval(updateCountdown, 1000); -let padTime = t => `${"0".repeat(2-t.toString().length)+t}`; + let unsyncedLeft = countdown.left - unsyncedSeconds; + // Countdown already over + if (unsyncedLeft <= 0) { + if(countdownTimer) clearInterval(countdownTimer); + if(syncTimer) clearInterval(syncTimer); + + $("#countdown").text("Time is up!"); + let end = new Date(Math.floor(countdown.start * 1000 + countdown.total * 1000)); + $("#subtext").text("This countdown ended on " + end.toLocaleDateString() + " " + end.toLocaleTimeString()); + return; + } + + let time = splitTimestamp(unsyncedLeft); + let text = formatTime(time.hours, time.minutes, time.seconds); + + sleep(time.milliseconds) + .then(() => $("#countdown").text(text)); +}; + +let unsyncTimer = undefined; + +let sync = () => { + $.getJSON({ + url: api_base + countdown_id, + success: async function(resp) { + if(unsyncTimer) clearInterval(unsyncTimer); + + let time = splitTimestamp(resp.left); + await sleep(time.milliseconds); + + countdown = resp; + countdown.left = ~~countdown.left; + unsyncedSeconds = 0; + unsyncTimer = setInterval(() => unsyncedSeconds++, 1000); + } + }); +}; + + + +// initialize the countdown +sync(); + +// update countdown from last sync and unsyncedSeconds in regular intervals +let countdownTimer = setInterval(updateCountdown, 100); +// sync in regular intervals +let syncTimer = setInterval(sync, 5000); diff --git a/countdown/countdown.py b/countdown/countdown.py new file mode 100644 index 0000000..b8a9001 --- /dev/null +++ b/countdown/countdown.py @@ -0,0 +1,45 @@ +from walrus import Walrus +from time import time + +from uuid import UUID, uuid4 +import struct + +class Cache: + __instance = None + + @staticmethod + def getInstance(): + if Cache.__instance == None: + Cache() + return Cache.__instance + + def __init__(self): + if Cache.__instance != None: + raise Exception("Cache is a singleton. Use Cache.getInstance()") + Cache.__instance = self + self.db = Walrus(host='localhost', port=6379, db=0) + + def add_countdown(self, total: float) -> UUID: + countdown_id = uuid4() + countdown = self.db.Hash(str(countdown_id)) + countdown.update(id=str(countdown_id), start=-1, total=total) + return self.get_countdown(str(countdown_id)) + + def start_countdown(self, id: UUID) -> dict: + countdown = self.db.Hash(str(id)) + countdown.update(start=time()) + return countdown.as_dict(decode=True) + + def stop_countdown(self, id: UUID) -> dict: + countdown = self.db.Hash(str(id)) + countdown.update(start=-1) + return countdown.as_dict(decode=True) + + def reset_countdown(self, id: UUID) -> dict: + countdown = self.db.Hash(str(id)) + countdown.update(start=time()) + return countdown.as_dict(decode=True) + + def get_countdown(self, id: UUID) -> dict: + countdown = self.db.Hash(str(id)) + return countdown.as_dict(decode=True) diff --git a/countdown/forms.py b/countdown/forms.py index e81027c..64c3e11 100644 --- a/countdown/forms.py +++ b/countdown/forms.py @@ -1,5 +1,12 @@ from flask_wtf import FlaskForm -from wtforms import TimeField +from wtforms import fields +from wtforms.fields import html5 +from wtforms.validators import Optional class CountdownAdminForm(FlaskForm): - totalTime = TimeField('Time') + hours = html5.IntegerField("Hours", [Optional()], + render_kw = {"min":0, "max":999, "placeholder": "00"}) + minutes = html5.IntegerField("Minutes", [Optional()], + render_kw = {"min":0, "max":59, "placeholder": "00"}) + seconds = html5.IntegerField("Seconds", [Optional()], + render_kw = {"min":0, "max":59, "placeholder": "00"}) diff --git a/countdown/templates/countdown/countdown_admin.html b/countdown/templates/countdown/countdown_admin.html deleted file mode 100644 index 5d3c123..0000000 --- a/countdown/templates/countdown/countdown_admin.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} -{% block title %}Countdown{% endblock title %} - -{% block body %} -Hello from Countdown -
-{% endblock body %} - -{% block scripts %} - -{% endblock scripts %} diff --git a/countdown/templates/countdown/create.html b/countdown/templates/countdown/create.html new file mode 100644 index 0000000..8f50a4b --- /dev/null +++ b/countdown/templates/countdown/create.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Countdown{% endblock title %} + +{% block body %} +
+ {{ form.csrf_token }} + {{ form.hours.label }}: {{ form.hours }} + {{ form.minutes.label }}: {{ form.minutes }} + {{ form.seconds.label }}: {{ form.seconds }} + +
+ +{% if clock %} +Clock: http://localhost:5000/countdown/{{ clock }} +{% endif %} +{% endblock body %} + +{% block scripts %} +{% endblock scripts %} diff --git a/countdown/templates/countdown/created.html b/countdown/templates/countdown/created.html new file mode 100644 index 0000000..6ec72f9 --- /dev/null +++ b/countdown/templates/countdown/created.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Countdown{% endblock title %} + +{% block body %} +{% for clock in clocks %} + +{% endfor %} +{% endblock body %} + +{% block scripts %} +{% endblock scripts %} diff --git a/countdown/views.py b/countdown/views.py index e05194e..6b7486a 100644 --- a/countdown/views.py +++ b/countdown/views.py @@ -1,20 +1,37 @@ -from flask import render_template, request, flash, redirect, url_for +from flask import render_template, request, flash, redirect, url_for, session from . import app from . import forms +from .countdown import Cache -@app.route('/', methods=['GET']) -def countdown(countdown_id): - return render_template('countdown/countdown.html', countdown_id=countdown_id) +@app.route('/', methods=['GET', 'POST']) +def create(): + session.permanent = True + if not session.get('created_countdowns'): + session['created_countdowns'] = [] -@app.route('/', methods=['GET', 'POST', 'PUT']) -def countdown_admin(): form = forms.CountdownAdminForm(request.form) if request.method == 'POST' and form.validate(): + cache = Cache.getInstance() + total = form.seconds.data or 0 + total += (form.minutes.data or 0) * 60 + total += (form.hours.data or 0) * 60 * 60 + + countdown_id = cache.add_countdown(total) # user = User(form.username.data, form.email.data, # form.password.data) # db_session.add(user) - flash('Thanks for registering') - return redirect(url_for('login')) + session['created_countdowns'].append(str(countdown_id)) + session.modified = True + return redirect(url_for('countdown.created')) + + return render_template('countdown/create.html', form=form, clock=None) + +@app.route('/mine', methods=['GET']) +def created(): + return render_template('countdown/created.html', clocks=session['created_countdowns']) + +@app.route('/', methods=['GET']) +def view(countdown_id): + return render_template('countdown/countdown.html', countdown_id=countdown_id) - return render_template('countdown/countdown_admin.html', form=form, clock=None) diff --git a/js/netclock.js b/js/netclock.js index 72a0a18..252db8e 100644 --- a/js/netclock.js +++ b/js/netclock.js @@ -5,3 +5,9 @@ import $ from 'jquery'; if (process.env.LOG_LEVEL) { log.setDefaultLevel(process.env.LOG_LEVEL); } + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export {sleep}; diff --git a/netclock.py b/netclock.py index 1bb05ed..bca2751 100644 --- a/netclock.py +++ b/netclock.py @@ -1,7 +1,11 @@ from flask import Flask + app = Flask(__name__) +app.secret_key = b'_5#y2L"F4Q8z\n\xec]/' +app.config.update(SESSION_COOKIE_SAMESITE='Strict') + +import views from countdown import app as countdown app.register_blueprint(countdown, url_prefix="/countdown") -import views diff --git a/querysheet.http b/querysheet.http index b9e82e1..a91cc2c 100644 --- a/querysheet.http +++ b/querysheet.http @@ -4,3 +4,15 @@ POST http://localhost:5000/countdown/api/v1 Content-Type: application/json {"total": "150"} + +# Set id +:id = cddabcb5-9da1-4ecb-ad36-2ca468da68e1 + +# Start +PATCH http://localhost:5000/countdown/api/v1/start/:id + +# Stop +PATCH http://localhost:5000/countdown/api/v1/stop/:id + +# Reset +PATCH http://localhost:5000/countdown/api/v1/reset/:id