diff --git a/a5/db.py b/a5/db.py index b5c3da2..e73dc2e 100644 --- a/a5/db.py +++ b/a5/db.py @@ -11,6 +11,8 @@ class Stats: co2_min: sqlite3.Row | None co2_max: sqlite3.Row | None + count: int + class DB: """Class for handling database interactions""" @@ -19,7 +21,7 @@ class DB: CREATE_SQL = """ CREATE TABLE IF NOT EXISTS measurements ( - timestamp integer not null primary key, + timestamp real not null primary key, tvoc real not null, co2 real not null ); @@ -36,7 +38,7 @@ CREATE TABLE IF NOT EXISTS measurements cursor.execute(self.CREATE_SQL) self._conn.commit() - def store(self, timestamp: int, tvoc: float, co2: float) -> int | None: + def store(self, timestamp: float, tvoc: float, co2: float) -> int | None: """Adds a measurement""" with closing(self._conn.cursor()) as cursor: cursor.execute( @@ -49,10 +51,10 @@ CREATE TABLE IF NOT EXISTS measurements def get_stats(self) -> Stats: """Returns measurement statistics""" + stats = Stats() with closing(self._conn.cursor()) as cursor: # this could probably be made into a single query, but the database being sqlite results in much lower latency with multiple selects # returned object could also be better typed - stats = Stats() cursor.execute( # sql "SELECT tvoc, timestamp FROM measurements ORDER BY tvoc DESC LIMIT 1" @@ -73,18 +75,27 @@ CREATE TABLE IF NOT EXISTS measurements "SELECT co2, timestamp FROM measurements ORDER BY co2 ASC LIMIT 1" ) stats.co2_min = cursor.fetchone() - return stats + stats.count = self.count() + return stats def get_page(self, page: int, page_size: int = 20) -> List[sqlite3.Row]: """Gets a paged list of all measurements. Page is 0 indexed.""" - with closing(self._conn.cursor()) as cur: - cur = self._conn.cursor() - cur.execute( + with closing(self._conn.cursor()) as cursor: + cursor.execute( # sql "SELECT timestamp, tvoc, co2 FROM measurements ORDER BY timestamp DESC LIMIT ?, ?", (page_size, page * page_size), ) - return cur.fetchall() + return cursor.fetchall() + + def count(self) -> int: + """Counts number of measurements""" + with closing(self._conn.cursor()) as cursor: + cursor.execute( + # sql + "SELECT COUNT(*) as count FROM MEASUREMENTS" + ) + return cursor.fetchone()["count"] def close(self) -> None: """Closes the DB connection""" diff --git a/a5/main.py b/a5/main.py index 2126fd6..bd44aa7 100644 --- a/a5/main.py +++ b/a5/main.py @@ -1,46 +1,64 @@ -from flask import Flask, g, redirect, render_template, request, url_for -from werkzeug.wrappers.response import Response +from time import time + +from flask import Flask, g, render_template, request +from flask.typing import ResponseReturnValue +from flask_htmx import HTMX from db import DB app = Flask(__name__) +htmx = HTMX(app) @app.route("/api/report", methods=["POST"]) -def report() -> str | Response: +def report() -> ResponseReturnValue: """Adds a measurement""" - timestamp = request.form["timestamp"] - tvoc = request.form["tvoc"] - co2 = request.form["co2"] + # timestamp = request.json["timestamp"] + tvoc = request.json["tvoc"] + co2 = request.json["co2"] try: - timestamp = int(timestamp) + # timestamp = int(timestamp) tvoc = float(tvoc) co2 = float(co2) except ValueError: return "Invalid data", 400 db = get_db() + timestamp = time() db.store(timestamp, tvoc, co2) return "ok", 200 -@app.route("/api/stats", methods=["GET"]) -def stats() -> Response: +@app.route("/stats", methods=["GET"]) +def stats() -> ResponseReturnValue: + """Gets overall statistics as well as few recent measurements""" db = get_db() - return db.get_stats() + stats = db.get_stats() + recents = db.get_page(0, 10) + template = "page/index.html.j2" + if htmx: + template = "block/index.html.j2" + return render_template(template, stats=stats, list=recents) -@app.route("/api/measurements", methods=["GET"]) -def measurements() -> Response: +@app.route("/measurements", methods=["GET"]) +def measurements() -> ResponseReturnValue: + """Lists measurements""" db = get_db() - page = request.get["page"] page_size = 20 + page = request.args["page"] try: page = int(page) if page < 0: raise ValueError() except ValueError: return "Invalid page", 400 - return db.get_page(page, page_size) + data = db.get_page(page, page_size) + count = db.count() + has_next_page = count > page * page_size + template = "page/measurements.html.j2" + if htmx: + template = "block/measurements.html.j2" + return render_template(template, has_next_page=has_next_page, page=page, list=data) def get_db() -> DB: diff --git a/a5/static/icon-right.svg b/a5/static/icon-right.svg new file mode 100644 index 0000000..142f94f --- /dev/null +++ b/a5/static/icon-right.svg @@ -0,0 +1 @@ + diff --git a/a5/static/styles.css b/a5/static/styles.css new file mode 100644 index 0000000..caa57d2 --- /dev/null +++ b/a5/static/styles.css @@ -0,0 +1,164 @@ +:root { + --font-mono: "Fira Code VF", "Fira Code", monospace; + --font-sans: "Fira Sans", sans-serif; + --primary-dark: #242038; + --primary: #9067c6; + --bg-dark: #100d11; + --bg: #110f1a; + --bg-success: #7ceec8; + --gray-700: oklch(37.3% 0.034 259.733); +} +html, +body { + margin: 0; + width: 100%; + height: 100%; + background: var(--bg); + font-family: var(--font-mono); +} + +code { + font-variant-ligatures: none; +} +* { + user-select: none; + box-sizing: border-box; + color: white; + text-decoration: none; + padding: 0; + margin: 0; + border: none; + background: none; +} + +.bg-gradient { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(to bottom, var(--primary), var(--bg)); + opacity: 0.06; + pointer-events: none; +} + +nav { + z-index: 100; + /*background: var(--bg);*/ + top: 0; + left: 50%; + transform: translateX(-50%); + position: fixed; + flex: 1; + align-items: center; + justify-content: space-between; + width: 100%; + max-width: 70rem; + padding: 0.5rem; + margin: auto; + text-transform: uppercase; + display: flex; +} + +.nav-spacer { + height: 3rem; +} + +nav .links { + display: flex; + gap: 0.5rem; +} + +.font-bold { + font-weight: bold; +} +.text-gray-500 { + color: #999; +} + +.btn { + isolation: isolate; + position: relative; + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.5rem; +} +.btn-border { + border: 1px solid var(--gray-700); +} + +.btn::before { + z-index: -1; + content: ""; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 0; + background: var(--primary); + transition: all 0.3s ease; +} + +.btn:hover::before { + width: 100%; + height: 100%; +} + +main { + max-width: 70rem; + margin: auto; + padding: 1rem; +} + +.rounded-full { + border-radius: 999px; +} + +.card { + display: flex; + gap: 1rem; + padding: 1rem; + border-radius: 0.5rem; + margin: 1rem 0; + background: var(--bg-dark); +} + +.card > img { + height: 5rem; + width: 5rem; +} + +.card .contents { + flex: 1; + flex-direction: column; + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.btn-link { + border: 1px solid var(--gray-700); +} + +.btn-link::after { + content: ""; + width: 1rem; + height: 1rem; + background-image: url("/static/icon-right.svg"); +} + +.btn-link-out { + border: 1px solid var(--gray-700); +} + +.btn-link-out::after { + content: ""; + width: 1rem; + height: 1rem; + background-image: url("/static/icon-link.svg"); +} + +.text-error { + color: red; +} diff --git a/a5/templates/base.html.j2 b/a5/templates/base.html.j2 new file mode 100644 index 0000000..2797ec1 --- /dev/null +++ b/a5/templates/base.html.j2 @@ -0,0 +1,49 @@ + + +
+ + + +Page {{page}}
+ +{% for row in list %} +