feat: frontend using django-vite + blabla

This commit is contained in:
2026-07-17 18:02:42 +02:00
parent 63a5a69c67
commit c06b184a2d
48 changed files with 13896 additions and 885 deletions
+6
View File
@@ -283,6 +283,12 @@ def log_dose(request: HttpRequest, payload: DoseLogIn):
# --- Notes -----------------------------------------------------------------
@router.get("/notes/daily", response=list[DailyNoteOut])
def list_daily_notes(request: HttpRequest):
"""The whole journal, newest first."""
return DailyNote.objects.all()
@router.post("/notes/daily", response=DayOut)
def save_daily_note(request: HttpRequest, payload: DailyNoteIn):
"""The day's journal entry. One per day; an empty body removes it."""
+44 -142
View File
@@ -248,117 +248,55 @@ class NoteApiTests(TrackerTestCase):
self.assertEqual(GlobalNote.objects.count(), 1)
class TodayPageTests(TrackerTestCase):
def test_the_page_requires_login(self):
response = self.client.get(reverse("tracker:today"))
self.assertEqual(response.status_code, 302)
self.assertIn("/admin/login/", response["Location"])
class AppShellTests(TrackerTestCase):
"""The pages are one Vue SPA; Django only serves the shell and its config."""
def test_the_page_lists_the_doses(self):
def test_the_pages_require_login(self):
for name in ("tracker:today", "tracker:recap"):
response = self.client.get(reverse(name))
self.assertEqual(response.status_code, 302)
self.assertIn("/admin/login/", response["Location"])
def test_both_routes_serve_the_spa_shell(self):
self.client.force_login(self.user)
for name in ("tracker:today", "tracker:recap"):
response = self.client.get(reverse(name))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'id="app"')
self.assertContains(response, "osiris-config")
def test_the_shell_sets_the_csrf_cookie_for_the_api(self):
self.client.force_login(self.user)
response = self.client.get(reverse("tracker:today"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Vetmedin")
self.assertContains(response, "Fortekor")
self.assertContains(response, "Morning")
self.assertContains(response, "Evening")
# Nothing ticked yet, so every dose reads as unrecorded rather than missed.
self.assertContains(response, "not recorded", count=3)
self.assertNotContains(response, "checked")
self.assertIn("csrftoken", response.cookies)
self.assertContains(response, "csrfToken")
def test_saving_ticks_doses_and_records_the_pulse(self):
self.client.force_login(self.user)
today = timezone.localdate()
response = self.client.post(
reverse("tracker:today"),
{
"date": today.isoformat(),
f"dose-{self.morning.id}": "on",
f"dose-{self.morning_only.id}": "on",
# The evening checkbox is absent: not given.
"bpm": "188",
},
@override_settings(VAPID_PUBLIC_KEY="BMn-test-public-key")
def test_the_config_carries_the_reminder_diagnostics(self):
self.morning.reminder_time = "08:00"
self.morning.save()
PushSubscription.objects.create(
user=self.user, endpoint="https://push.example/a", p256dh="k", auth="s"
)
self.assertEqual(response.status_code, 302)
self.assertTrue(DoseLog.objects.get(scheduled_dose=self.morning).taken)
self.assertTrue(DoseLog.objects.get(scheduled_dose=self.morning_only).taken)
self.assertFalse(DoseLog.objects.get(scheduled_dose=self.evening).taken)
self.assertEqual(PulseReading.objects.get(date=today).bpm, 188)
def test_clearing_the_pulse_field_removes_the_reading(self):
self.client.force_login(self.user)
today = timezone.localdate()
PulseReading.objects.create(date=today, bpm=200)
self.client.post(
reverse("tracker:today"), {"date": today.isoformat(), "bpm": ""}
)
self.assertFalse(PulseReading.objects.filter(date=today).exists())
def test_saving_records_the_daily_note(self):
self.client.force_login(self.user)
today = timezone.localdate()
self.client.post(
reverse("tracker:today"),
{"date": today.isoformat(), "note": "Ate well."},
)
self.assertEqual(DailyNote.objects.get(date=today).body, "Ate well.")
def test_clearing_the_note_field_removes_the_note(self):
self.client.force_login(self.user)
today = timezone.localdate()
DailyNote.objects.create(date=today, body="Old news.")
self.client.post(reverse("tracker:today"), {"date": today.isoformat()})
self.assertFalse(DailyNote.objects.filter(date=today).exists())
def test_the_shared_note_form_saves_without_touching_the_day(self):
self.client.force_login(self.user)
today = timezone.localdate()
response = self.client.post(
reverse("tracker:today"),
{"date": today.isoformat(), "global_note": "Vet on Friday."},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(GlobalNote.load().body, "Vet on Friday.")
# Its own form carries no dose or pulse fields; the day must stay untouched.
self.assertFalse(DoseLog.objects.exists())
self.assertFalse(PulseReading.objects.exists())
self.assertFalse(DailyNote.objects.exists())
def test_the_page_shows_both_notes(self):
self.client.force_login(self.user)
DailyNote.objects.create(body="More playful than yesterday.")
note = GlobalNote.load()
note.body = "Vet on Friday."
note.save()
response = self.client.get(reverse("tracker:today"))
self.assertContains(response, "More playful than yesterday.")
self.assertContains(response, "Vet on Friday.")
self.assertContains(response, "BMn-test-public-key")
self.assertContains(response, '"activeDoses": 3')
self.assertContains(response, '"dosesWithReminders": 1')
self.assertContains(response, '"subscribedDevices": 1')
def test_the_page_links_to_the_recap(self):
@override_settings(VAPID_PUBLIC_KEY="", VAPID_PRIVATE_KEY="")
def test_missing_vapid_keys_reach_the_frontend_as_an_empty_string(self):
# The frontend's diagnostics card explains the situation to the user.
self.client.force_login(self.user)
response = self.client.get(reverse("tracker:today"))
self.assertContains(response, reverse("tracker:recap"))
self.assertContains(response, '"vapidPublicKey": ""')
class RecapTests(TrackerTestCase):
def test_the_page_requires_login(self):
response = self.client.get(reverse("tracker:recap"))
self.assertEqual(response.status_code, 302)
self.assertIn("/admin/login/", response["Location"])
def test_a_fully_ticked_day_reads_as_complete(self):
today = timezone.localdate()
for dose in (self.morning, self.morning_only, self.evening):
@@ -393,27 +331,19 @@ class RecapTests(TrackerTestCase):
self.assertEqual(recap[1]["note"], "Ate well.")
self.assertIsNone(recap[0]["bpm"])
def test_the_page_shows_the_table_graph_and_notes(self):
self.client.force_login(self.user)
today = timezone.localdate()
PulseReading.objects.create(date=today - timedelta(days=1), bpm=170)
PulseReading.objects.create(date=today, bpm=190)
DailyNote.objects.create(date=today, body="More playful than yesterday.")
for dose in (self.morning, self.morning_only, self.evening):
DoseLog.objects.create(scheduled_dose=dose, date=today, taken=True)
def test_the_recap_lists_every_daily_note(self):
DailyNote.objects.create(body="Today's entry.")
DailyNote.objects.create(
date=timezone.localdate() - timedelta(days=30), body="A month ago."
)
response = self.client.get(reverse("tracker:recap"))
response = self.client.get(
"/api/tracker/notes/daily", **self.auth_header()
).json()
self.assertContains(response, "")
self.assertContains(response, "0/3") # every earlier day is incomplete
self.assertContains(response, "<polyline")
self.assertContains(response, "180.0 bpm") # average
self.assertContains(response, "More playful than yesterday.")
def test_the_graph_explains_itself_without_readings(self):
self.client.force_login(self.user)
response = self.client.get(reverse("tracker:recap"))
self.assertContains(response, "at least two days")
self.assertEqual(
[n["body"] for n in response], ["Today's entry.", "A month ago."]
)
class PwaTests(TrackerTestCase):
@@ -431,34 +361,6 @@ class PwaTests(TrackerTestCase):
self.assertContains(response, 'rel="manifest"')
self.assertContains(response, "serviceWorker.register")
@override_settings(VAPID_PUBLIC_KEY="", VAPID_PRIVATE_KEY="")
def test_the_reminder_card_explains_itself_when_keys_are_missing(self):
# It used to hide entirely, which is indistinguishable from a broken page.
self.client.force_login(self.user)
response = self.client.get(reverse("tracker:today"))
self.assertContains(response, "Reminders")
self.assertContains(response, "Diagnostics")
self.assertContains(response, "generate_vapid_keys")
@override_settings(VAPID_PUBLIC_KEY="BMn-test-public-key")
def test_the_diagnostics_report_reminder_times_and_devices(self):
self.client.force_login(self.user)
response = self.client.get(reverse("tracker:today"))
self.assertContains(response, "Doses with a reminder time: 0 of 3")
self.assertContains(response, "Devices subscribed (this account): 0")
self.morning.reminder_time = "08:00"
self.morning.save()
PushSubscription.objects.create(
user=self.user, endpoint="https://push.example/a", p256dh="k", auth="s"
)
response = self.client.get(reverse("tracker:today"))
self.assertContains(response, "Doses with a reminder time: 1 of 3")
self.assertContains(response, "Devices subscribed (this account): 1")
def test_the_manifest_is_valid_json_pointing_at_real_icons(self):
response = self.client.get(reverse("manifest"))
+3 -2
View File
@@ -4,7 +4,8 @@ from tracker import views
app_name = "tracker"
# Both routes serve the same SPA shell; Vue's router picks the view.
urlpatterns = [
path("today/", views.today, name="today"),
path("recap/", views.recap, name="recap"),
path("today/", views.app, name="today"),
path("recap/", views.app, name="recap"),
]
+36 -130
View File
@@ -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(),
}
},
)