feat: frontend using django-vite + blabla
This commit is contained in:
+36
-130
@@ -1,148 +1,54 @@
|
||||
from datetime import timedelta
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.http import FileResponse, Http404
|
||||
from django.middleware.csrf import get_token
|
||||
from django.shortcuts import render
|
||||
|
||||
from tracker.api import build_day, build_recap, pulse_trend
|
||||
from tracker.models import DailyNote, DoseLog, GlobalNote, PulseReading, ScheduledDose
|
||||
|
||||
CHART_WIDTH = 320
|
||||
CHART_HEIGHT = 140
|
||||
CHART_PADDING = 24
|
||||
from tracker.models import ScheduledDose
|
||||
|
||||
|
||||
def build_chart(readings: list[PulseReading]) -> Optional[dict]:
|
||||
"""Lay the readings out as SVG coordinates, so the page needs no JavaScript."""
|
||||
if len(readings) < 2:
|
||||
return None
|
||||
def service_worker(request):
|
||||
"""The built worker (frontend/src/sw.ts), from the root so its scope covers /admin/.
|
||||
|
||||
bpms = [r.bpm for r in readings]
|
||||
low, high = min(bpms), max(bpms)
|
||||
# A flat line would divide by zero, and a narrow range exaggerates noise.
|
||||
span = max(high - low, 10)
|
||||
mid = (high + low) / 2
|
||||
low, high = mid - span / 2, mid + span / 2
|
||||
Served by a view rather than the staticfiles machinery because the URL must
|
||||
stay stable and unhashed — a renamed worker would be a brand-new worker.
|
||||
"""
|
||||
path = finders.find("sw.js")
|
||||
if path is None:
|
||||
raise Http404("Frontend not built yet — run: make frontend")
|
||||
|
||||
first_day = readings[0].date
|
||||
total_days = max((readings[-1].date - first_day).days, 1)
|
||||
|
||||
inner_width = CHART_WIDTH - 2 * CHART_PADDING
|
||||
inner_height = CHART_HEIGHT - 2 * CHART_PADDING
|
||||
|
||||
points = []
|
||||
for reading in readings:
|
||||
x = CHART_PADDING + (reading.date - first_day).days / total_days * inner_width
|
||||
y = CHART_PADDING + (high - reading.bpm) / (high - low) * inner_height
|
||||
points.append({"x": round(x, 1), "y": round(y, 1), "reading": reading})
|
||||
|
||||
return {
|
||||
"points": points,
|
||||
"polyline": " ".join(f"{p['x']},{p['y']}" for p in points),
|
||||
"low": round(low),
|
||||
"high": round(high),
|
||||
"average": round(sum(bpms) / len(bpms), 1),
|
||||
"trend": pulse_trend(readings),
|
||||
"width": CHART_WIDTH,
|
||||
"height": CHART_HEIGHT,
|
||||
"first_date": first_day,
|
||||
"last_date": readings[-1].date,
|
||||
}
|
||||
response = FileResponse(open(path, "rb"), content_type="application/javascript")
|
||||
# Browsers cap service-worker caching anyway; make updates immediate.
|
||||
response["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def today(request):
|
||||
"""The main screen: tick off the day's doses and record the pulse."""
|
||||
day = parse_date(request.GET.get("date", "")) or timezone.localdate()
|
||||
def app(request):
|
||||
"""The single-page app shell: Vue routes between Today and Recap client-side.
|
||||
|
||||
if request.method == "POST":
|
||||
day = parse_date(request.POST.get("date", "")) or timezone.localdate()
|
||||
|
||||
# The shared note has its own form and save button, so that editing it
|
||||
# while looking at a past day cannot touch that day's records.
|
||||
if "global_note" in request.POST:
|
||||
note = GlobalNote.load()
|
||||
note.body = request.POST["global_note"].strip()
|
||||
note.save(update_fields=["body", "updated_at"])
|
||||
messages.success(request, "Shared note saved.")
|
||||
return redirect(f"{reverse('tracker:today')}?date={day.isoformat()}")
|
||||
|
||||
doses = ScheduledDose.objects.filter(is_active=True, medication__is_active=True)
|
||||
|
||||
for dose in doses:
|
||||
taken = f"dose-{dose.id}" in request.POST
|
||||
log, _ = DoseLog.objects.get_or_create(
|
||||
scheduled_dose=dose, date=day, defaults={"taken": taken}
|
||||
)
|
||||
log.taken = taken
|
||||
log.save()
|
||||
|
||||
bpm = (request.POST.get("bpm") or "").strip()
|
||||
if bpm:
|
||||
try:
|
||||
PulseReading.objects.update_or_create(
|
||||
date=day, defaults={"bpm": int(bpm)}
|
||||
)
|
||||
except ValueError:
|
||||
messages.error(request, f"'{bpm}' is not a valid pulse.")
|
||||
else:
|
||||
# Clearing the field removes the reading for that day.
|
||||
PulseReading.objects.filter(date=day).delete()
|
||||
|
||||
note_body = (request.POST.get("note") or "").strip()
|
||||
if note_body:
|
||||
DailyNote.objects.update_or_create(date=day, defaults={"body": note_body})
|
||||
else:
|
||||
DailyNote.objects.filter(date=day).delete()
|
||||
|
||||
messages.success(request, f"Saved for {day:%A %d %B}.")
|
||||
return redirect(f"{reverse('tracker:today')}?date={day.isoformat()}")
|
||||
|
||||
return render(
|
||||
request,
|
||||
"tracker/today.html",
|
||||
{
|
||||
"title": "Today",
|
||||
"day": build_day(day),
|
||||
"is_today": day == timezone.localdate(),
|
||||
"previous_date": day - timedelta(days=1),
|
||||
"next_date": day + timedelta(days=1),
|
||||
"global_note": GlobalNote.load(),
|
||||
# Empty when push is not configured; the card then says so rather than
|
||||
# vanishing, because a missing card looks like a bug in the page.
|
||||
"vapid_public_key": settings.VAPID_PUBLIC_KEY,
|
||||
"doses_with_reminders": ScheduledDose.objects.filter(
|
||||
is_active=True,
|
||||
medication__is_active=True,
|
||||
reminder_time__isnull=False,
|
||||
).count(),
|
||||
"subscribed_devices": request.user.push_subscriptions.count(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def recap(request):
|
||||
"""The overview: dose completeness per day, the pulse graph, and the journal."""
|
||||
readings = list(
|
||||
PulseReading.objects.filter(
|
||||
date__gte=timezone.localdate() - timedelta(days=90)
|
||||
).order_by("date")
|
||||
All data flows through the API; the shell only carries the per-page-load
|
||||
facts the frontend cannot fetch (CSRF token) or that feed the reminder
|
||||
diagnostics.
|
||||
"""
|
||||
active_doses = ScheduledDose.objects.filter(
|
||||
is_active=True, medication__is_active=True
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"tracker/recap.html",
|
||||
"tracker/app.html",
|
||||
{
|
||||
"title": "Recap",
|
||||
"recap_days": build_recap(14),
|
||||
"chart": build_chart(readings),
|
||||
"reading_count": len(readings),
|
||||
"daily_notes": DailyNote.objects.all(),
|
||||
"config": {
|
||||
"vapidPublicKey": settings.VAPID_PUBLIC_KEY,
|
||||
# get_token also sets the CSRF cookie for the session.
|
||||
"csrfToken": get_token(request),
|
||||
"activeDoses": active_doses.count(),
|
||||
"dosesWithReminders": active_doses.filter(
|
||||
reminder_time__isnull=False
|
||||
).count(),
|
||||
"subscribedDevices": request.user.push_subscriptions.count(),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user