From 0a9da4f9c5d435a45a4e04902a2d61ec3d21aa84 Mon Sep 17 00:00:00 2001 From: Mateusz Date: Tue, 3 Nov 2026 08:44:11 +0100 Subject: [PATCH] calendar --- server/README.md | 22 +++- server/app.py | 179 ++++++++++++++++++++++++++++ server/sensors_app.py | 53 ++++++++ server/sensors_client.py | 5 + server/static/retro.css | 34 ++++++ server/templates/calendar.html | 96 +++++++++++++++ server/templates/clipboard.html | 1 + server/templates/docs.html | 1 + server/templates/home.html | 5 +- server/templates/notes.html | 1 + server/templates/notifications.html | 1 + server/templates/sensors.html | 1 + server/tests/test_split_apps.py | 28 +++++ 13 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 server/templates/calendar.html diff --git a/server/README.md b/server/README.md index 53abe8f..8d6c80b 100644 --- a/server/README.md +++ b/server/README.md @@ -3,9 +3,9 @@ Two Flask services with separate responsibilities and separate databases: - **main app** (`app.py`) — all user-facing views: login, home, notes, - notifications, clipboard, API docs and the `/sensors` dashboard. It owns the - **main app** database (`main_app.db`: users, notes, notifications and clipboard - file metadata) and proxies data requests to the sensors API. + notifications, clipboard, calendar, API docs and the `/sensors` dashboard. It + owns the **main app** database (`main_app.db`: users, notes, notifications and + clipboard file metadata) and proxies data requests to the sensors API. - **sensors app** (`sensors_app.py`) — API only. It owns the **sensors** database (`sensors.db`, tables `sensor` and `sensor_readout`) and has no knowledge of users. Its API is open to every user on the local machine. @@ -56,6 +56,7 @@ server/ ├── notes.html ├── notifications.html ├── clipboard.html + ├── calendar.html ├── docs.html └── sensors.html ``` @@ -187,6 +188,19 @@ automatically): alembic -c alembic_users.ini upgrade head ``` +## Calendar + +The main app provides a `/calendar` page with two views for browsing sensor +readouts: + +- **month** (`/calendar?view=month&year=YYYY&month=MM`) — calendar grid of the + given month, each day lists readouts (sensor, time, type, value) +- **week** (`/calendar?view=week&date=YYYY-MM-DD`) — list of the seven days of + the week containing the given date + +Readouts are fetched from the sensors API through the +`GET /api/sensors/:id/readouts/range?from=...&to=...` endpoint. + ## Notes Logged-in users manage their own private short notes at `/notes`. Each note has @@ -349,7 +363,7 @@ deployment path or user is different. | Service | Port | Routes | |---|---|---| -| main | 8000 | `/`, `/login`, `/home`, `/clipboard`, `/notes`, `/notifications`, `/sensors`, `/api/docs`, `/api/*` (proxy) | +| main | 8000 | `/`, `/login`, `/home`, `/clipboard`, `/calendar`, `/notes`, `/notifications`, `/sensors`, `/api/docs`, `/api/*` (proxy) | | sensors | 8001 | `/api/*` (sensors and readouts) | ### Tests diff --git a/server/app.py b/server/app.py index 8726ef5..42b02fb 100644 --- a/server/app.py +++ b/server/app.py @@ -2,7 +2,9 @@ import fcntl import os import uuid from contextlib import contextmanager +from datetime import date, datetime, timedelta, timezone from pathlib import Path +from zoneinfo import ZoneInfo import click from flask import ( @@ -49,6 +51,34 @@ def format_bytes(value: int) -> str: return f"{int(size)} B" +LOCAL_TZ_DEFAULT = "Europe/Warsaw" + + +def _calendar_tz(name): + try: + return ZoneInfo(name) + except Exception: + return ZoneInfo(LOCAL_TZ_DEFAULT) + + +def _parse_date_arg(raw): + value = (raw or "").strip() + if not value: + return None + try: + return date.fromisoformat(value) + except ValueError: + return None + + +def _day_bounds_utc(day: date, tz): + start_local = datetime(day.year, day.month, day.day) + end_local = start_local + timedelta(days=1) + start_utc = start_local.replace(tzinfo=tz).astimezone(timezone.utc).replace(tzinfo=None) + end_utc = end_local.replace(tzinfo=tz).astimezone(timezone.utc).replace(tzinfo=None) + return start_utc, end_utc + + def create_app(): app = Flask(__name__) @@ -326,6 +356,155 @@ def create_app(): flash(f"Deleted {name}.", "success") return redirect(url_for("clipboard_page")) + @app.route("/api/sensors//readouts/range") + @login_required + def api_sensor_readouts_range(sensor_id): + return _proxy( + lambda: sensors.sensor_readouts_range(sensor_id, request.args.to_dict()) + ) + + @app.route("/calendar") + @login_required + def calendar_page(): + view = (request.args.get("view") or "month").strip().lower() + if view not in ("month", "week"): + view = "month" + tz = _calendar_tz(request.args.get("tz") or LOCAL_TZ_DEFAULT) + now_local = datetime.now(tz) + + year = now_local.year + month = now_local.month + week_start = None + week_end = None + week_prev = None + week_next = None + days = [] + month_weeks = None + + if view == "week": + week_date = _parse_date_arg(request.args.get("date")) or now_local.date() + week_start = week_date - timedelta(days=week_date.weekday()) + week_end = week_start + timedelta(days=6) + week_prev = week_start - timedelta(days=7) + week_next = week_start + timedelta(days=7) + day_list = [week_start + timedelta(days=i) for i in range(7)] + range_from = _day_bounds_utc(week_start, tz)[0] + range_to = _day_bounds_utc(week_end + timedelta(days=1), tz)[0] + else: + year = int(request.args.get("year") or now_local.year) + month = int(request.args.get("month") or now_local.month) + if not 1 <= month <= 12: + month = 1 + first_day = date(year, month, 1) + next_year = year + 1 if month == 12 else year + next_month = 1 if month == 12 else month + 1 + last_day = date(next_year, next_month, 1) - timedelta(days=1) + day_list = [] + current = first_day + while current <= last_day: + day_list.append(current) + current += timedelta(days=1) + range_from, _ = _day_bounds_utc(first_day, tz) + range_to, _ = _day_bounds_utc(last_day, tz) + week_start = first_day - timedelta(days=first_day.weekday()) + week_end = last_day + + readouts_by_day = {day: [] for day in day_list} + sensor_names = {} + try: + listed = sensors.list_sensors() + for sensor in listed[1] or []: + sensor_id = int(sensor["id"]) + sensor_names[sensor_id] = sensor["name"] + status, payload = sensors.sensor_readouts_range( + sensor_id, + {"from": range_from.isoformat(), "to": range_to.isoformat()}, + ) + if status != 200: + continue + for item in payload or []: + raw_ts = (item.get("timestamp") or "").rstrip("Z") + ts = datetime.fromisoformat(raw_ts) if raw_ts else None + if ts is None: + continue + local_day = ts.replace(tzinfo=timezone.utc).astimezone(tz).date() + if local_day in readouts_by_day: + readouts_by_day[local_day].append( + { + "id": item.get("id"), + "sensor_id": item.get("sensor_id") or sensor_id, + "type": item.get("type"), + "value": item.get("value"), + "timestamp": ts, + } + ) + except SensorsAPIError: + pass + + days = [ + {"date": day, "readouts": readouts_by_day.get(day, [])} + for day in day_list + ] + + month_weeks = None + if view == "month": + first_monday = week_start + last_sunday = week_end + timedelta(days=6 - week_end.weekday()) + end_exclusive = last_sunday + timedelta(days=1) + weeks = [] + week = [] + current = first_monday + while current < end_exclusive: + week.append( + { + "date": current, + "in_month": current.month == month, + "readouts": readouts_by_day.get(current, []), + } + ) + if current.weekday() == 6: + weeks.append(week) + week = [] + current += timedelta(days=1) + if week: + weeks.append(week) + month_weeks = weeks + + if month == 1: + prev_year, prev_month = year - 1, 12 + else: + prev_year, prev_month = year, month - 1 + if month == 12: + next_year, next_month = year + 1, 1 + else: + next_year, next_month = year, month + 1 + + month_names = ( + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", + ) + month_label = f"{month_names[month - 1]} {year}" + + return render_template( + "calendar.html", + user=current_user, + view=view, + year=year, + month=month, + week_start=week_start, + week_end=week_end, + days=days, + month_weeks=month_weeks, + sensor_names=sensor_names, + prev_year=prev_year, + prev_month=prev_month, + next_year=next_year, + next_month=next_month, + week_prev=week_prev, + week_next=week_next, + today=now_local.date(), + ) + # --- Notifications (stored in main_app.db) --- @app.route("/notifications") diff --git a/server/sensors_app.py b/server/sensors_app.py index 10e692f..2d0b8a7 100644 --- a/server/sensors_app.py +++ b/server/sensors_app.py @@ -7,6 +7,20 @@ from config import sensors_database_uri from models import Sensor, SensorReadout, db +def _parse_readout_ts(raw: str): + value = (raw or "").strip() + if not value: + return None + if value.endswith("Z"): + value = value[:-1] + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return None + if parsed.tzinfo is not None: + parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None) + return parsed + def create_app(): app = Flask(__name__) @@ -78,6 +92,45 @@ def create_app(): } ), 201 + @app.route("/api/sensors//readouts/range") + def api_sensor_readouts_range(sensor_id): + sensor = db.session.get(Sensor, sensor_id) + if not sensor: + return jsonify({"error": "sensor not found"}), 404 + + from_raw = request.args.get("from") + to_raw = request.args.get("to") + from_ts = _parse_readout_ts(from_raw) if from_raw else None + to_ts = _parse_readout_ts(to_raw) if to_raw else None + if from_ts is None or to_ts is None or to_ts < from_ts: + return jsonify({"error": "from and to are required and valid"}), 400 + + rows = ( + db.session.query( + SensorReadout.id, + SensorReadout.sensor_id, + SensorReadout.type, + SensorReadout.value, + SensorReadout.timestamp, + ) + .filter(SensorReadout.sensor_id == sensor_id) + .filter(SensorReadout.timestamp >= from_ts, SensorReadout.timestamp < to_ts) + .order_by(SensorReadout.timestamp) + .all() + ) + return jsonify( + [ + { + "id": row[0], + "sensor_id": row[1], + "type": row[2], + "value": row[3], + "timestamp": row[4].isoformat() + "Z", + } + for row in rows + ] + ) + # --- API: dashboard reads (public on the local machine) --- @app.route("/api/sensors//types") diff --git a/server/sensors_client.py b/server/sensors_client.py index 17d5517..f4c01ac 100644 --- a/server/sensors_client.py +++ b/server/sensors_client.py @@ -62,6 +62,11 @@ class SensorsClient: def sensor_readouts(self, sensor_id, params=None): return self._request("GET", f"/api/sensors/{sensor_id}/readouts", params=params) + def sensor_readouts_range(self, sensor_id, params=None): + return self._request( + "GET", f"/api/sensors/{sensor_id}/readouts/range", params=params + ) + def sensor_readouts_hourly(self, sensor_id, params=None): return self._request( "GET", f"/api/sensors/{sensor_id}/readouts/hourly", params=params diff --git a/server/static/retro.css b/server/static/retro.css index 2d157a4..d95b684 100644 --- a/server/static/retro.css +++ b/server/static/retro.css @@ -491,3 +491,37 @@ textarea { letter-spacing: 0; } .clipboard-delete-form button:hover { background: var(--mc-red-bright); } + +/* --- Calendar --- */ +.calendar-controls { display: flex; gap: 0.8em; align-items: center; margin-top: 0.5rem; } +.calendar-title-row { display: flex; align-items: center; justify-content: space-between; gap: 0.8em; margin-top: 0.5rem; } +.calendar-grid { display: flex; flex-direction: column; gap: 0.4em; margin-top: 1rem; } +.calendar-week { display: grid; grid-template-columns: repeat(7, 1fr); gap: 0.4em; } +.calendar-day { border: 2px solid var(--mc-dim); background: var(--mc-deep); min-height: 6em; padding: 0.3em; width: 100%; box-sizing: border-box; } +.calendar-day-outside { opacity: 0.55; } +.calendar-day-header { color: var(--mc-yellow); text-transform: uppercase; letter-spacing: 1px; font-size: 0.8em; } +.calendar-readouts { list-style: none; padding: 0; margin: 0.3em 0 0; } +.calendar-event { border-bottom: 1px dashed var(--mc-dim); padding: 0.15em 0; font-size: 0.9em; } +.calendar-event-time { color: var(--mc-cyan); margin-right: 0.4em; } +.calendar-event-sensor { color: var(--mc-bright); } +.calendar-event-type { color: var(--mc-dim); } +.calendar-event-value { float: right; color: var(--mc-yellow); } +.calendar-list { list-style: none; padding: 0; margin: 1rem 0; } +.calendar-day-item { border: 2px solid var(--mc-dim); background: var(--mc-deep); margin: 0.5rem 0; padding: 0.4em 0.6em; min-height: 6em; width: 100%; box-sizing: border-box; } +.calendar-nav { display: flex; gap: 0.8em; margin-top: 0.8rem; } +.calendar-today { + display: inline-block; + text-transform: uppercase; + letter-spacing: 2px; + background: var(--mc-yellow); + color: var(--mc-black); + box-shadow: 4px 4px 0 var(--mc-black); + padding: 0.4em 1.2em; + text-decoration: none; +} +.calendar-today:hover { background: var(--mc-cyan); color: var(--mc-black); } +.calendar-day.calendar-today, +.calendar-day-item.calendar-today { + background: var(--mc-deep); + border: 3px solid var(--mc-yellow); +} diff --git a/server/templates/calendar.html b/server/templates/calendar.html new file mode 100644 index 0000000..b10d891 --- /dev/null +++ b/server/templates/calendar.html @@ -0,0 +1,96 @@ + + + + + + Calendar — Yggdrasil + + + + + + +

Calendar

+ +
+

Readouts

+
+ MMonth + WWeek + {% if view == 'month' %} + TToday + < + > + {% else %} + TToday + {% endif %} +
+ {% if view == 'month' %} +
+

{{ month_label }}

+
+ +
+ {% for week in month_weeks %} +
+ {% for day in week %} +
+
{{ day.date.strftime("%a %d %b") }}
+
    + {% for r in day.readouts %} +
  • + {{ r.timestamp.strftime("%H:%M") }} + {{ sensor_names[r.sensor_id] }} + {{ r.type }} + {{ r.value|round(1) }} +
  • + {% endfor %} +
+
+ {% endfor %} +
+ {% endfor %} +
+ {% else %} +
+

Tydzień {{ week_start }} – {{ week_end }}

+
+ < + > +
+
+
    + {% for day in days %} +
  • +
    {{ day.date.strftime("%Y-%m-%d (%a)") }}
    +
      + {% for r in day.readouts %} +
    • + {{ r.timestamp.strftime("%H:%M") }} + {{ sensor_names[r.sensor_id] }} + {{ r.type }} + {{ r.value|round(1) }} +
    • + {% endfor %} +
    +
  • + {% endfor %} +
+ {% endif %} +
+ +
+ {{ user.username }}@yggdrasil:~/calendar$ +
+ + diff --git a/server/templates/clipboard.html b/server/templates/clipboard.html index 07317b4..9a3f94e 100644 --- a/server/templates/clipboard.html +++ b/server/templates/clipboard.html @@ -14,6 +14,7 @@ Sensors Notes Clipboard + Calendar Notifications{% if unread_notifications %} {{ unread_notifications }}{% endif %} API Log out diff --git a/server/templates/docs.html b/server/templates/docs.html index 2437ab7..53ecb91 100644 --- a/server/templates/docs.html +++ b/server/templates/docs.html @@ -14,6 +14,7 @@ Sensors Notes Clipboard + Calendar Notifications{% if unread_notifications %} {{ unread_notifications }}{% endif %} API Log out diff --git a/server/templates/home.html b/server/templates/home.html index b867d23..ba69aec 100644 --- a/server/templates/home.html +++ b/server/templates/home.html @@ -14,6 +14,7 @@ Sensors Notes Clipboard + Calendar Notifications{% if unread_notifications %} {{ unread_notifications }}{% endif %} API Log out @@ -29,9 +30,10 @@
  • 3Notifications{% if unread_notifications %} ({{ unread_notifications }} new){% endif %}
  • 4API — documentation
  • 5Clipboard — upload and share files
  • +
  • 6Calendar — sensor readings
  • 0Log out
  • -

    Tip: press 1, 2, 3, 4, 5 or 0 to navigate.

    +

    Tip: press 1, 2, 3, 4, 5, 6 or 0 to navigate.