feat: add recap page

This commit is contained in:
2026-07-17 17:19:26 +02:00
parent 0cfe4c7108
commit 63a5a69c67
6 changed files with 281 additions and 58 deletions
+63 -1
View File
@@ -2,7 +2,7 @@ from datetime import date as date_type
from datetime import datetime, timedelta
from typing import Optional
from django.db.models import Avg, Max, Min
from django.db.models import Avg, Count, Max, Min
from django.http import HttpRequest
from django.shortcuts import get_object_or_404
from django.utils import timezone
@@ -81,6 +81,17 @@ class DayOut(Schema):
note: Optional[DailyNoteOut]
class RecapDayOut(Schema):
"""One line of the recap: did every dose go out that day, and what was the pulse."""
date: date_type
taken: int
expected: int
complete: bool
bpm: Optional[int]
note: str
class DoseLogIn(Schema):
scheduled_dose_id: int
date: Optional[date_type] = None
@@ -147,6 +158,51 @@ def build_day(day: date_type) -> dict:
}
def build_recap(days: int) -> list[dict]:
"""One line per day, newest first: dose completeness, pulse and note.
"Complete" is measured against the doses that are active now, not the plan
as it stood on that day — the point is spotting gaps, not auditing history.
"""
today = timezone.localdate()
since = today - timedelta(days=days - 1)
expected = ScheduledDose.objects.filter(
is_active=True, medication__is_active=True
).count()
taken_by_day = {
row["date"]: row["taken"]
for row in DoseLog.objects.filter(
date__gte=since,
taken=True,
scheduled_dose__is_active=True,
scheduled_dose__medication__is_active=True,
)
.values("date")
.annotate(taken=Count("id"))
}
pulse_by_day = {
reading.date: reading.bpm
for reading in PulseReading.objects.filter(date__gte=since)
}
note_by_day = {
note.date: note.body for note in DailyNote.objects.filter(date__gte=since)
}
return [
{
"date": day,
"taken": taken_by_day.get(day, 0),
"expected": expected,
"complete": expected > 0 and taken_by_day.get(day, 0) == expected,
"bpm": pulse_by_day.get(day),
"note": note_by_day.get(day, ""),
}
for day in (today - timedelta(days=offset) for offset in range(days))
]
def pulse_trend(readings: list[PulseReading]) -> Optional[float]:
"""Least-squares slope in bpm/day: ~0 means stable, positive means climbing."""
if len(readings) < 2:
@@ -202,6 +258,12 @@ def get_day(request: HttpRequest, date: Optional[date_type] = None):
return build_day(date or timezone.localdate())
@router.get("/recap", response=list[RecapDayOut])
def get_recap(request: HttpRequest, days: int = 14):
"""Day-by-day recap, newest first: were all doses given, pulse and note."""
return build_recap(days)
@router.post("/doses", response=DayOut)
def log_dose(request: HttpRequest, payload: DoseLogIn):
"""Tick a dose off (or untick it). Idempotent per (dose, day)."""
+60 -6
View File
@@ -347,19 +347,73 @@ class TodayPageTests(TrackerTestCase):
self.assertContains(response, "More playful than yesterday.")
self.assertContains(response, "Vet on Friday.")
def test_the_graph_appears_once_there_are_two_readings(self):
def test_the_page_links_to_the_recap(self):
self.client.force_login(self.user)
response = self.client.get(reverse("tracker:today"))
self.assertContains(response, reverse("tracker:recap"))
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):
DoseLog.objects.create(scheduled_dose=dose, date=today, taken=True)
recap = self.client.get("/api/tracker/recap", **self.auth_header()).json()
self.assertEqual(recap[0]["date"], today.isoformat())
self.assertTrue(recap[0]["complete"])
self.assertEqual(recap[0]["taken"], 3)
self.assertEqual(recap[0]["expected"], 3)
def test_a_missed_or_unrecorded_dose_leaves_the_day_incomplete(self):
today = timezone.localdate()
DoseLog.objects.create(scheduled_dose=self.morning, date=today, taken=True)
DoseLog.objects.create(scheduled_dose=self.evening, date=today, taken=False)
# morning_only is not recorded at all.
recap = self.client.get("/api/tracker/recap", **self.auth_header()).json()
self.assertFalse(recap[0]["complete"])
self.assertEqual(recap[0]["taken"], 1)
def test_the_recap_carries_pulse_and_note(self):
yesterday = timezone.localdate() - timedelta(days=1)
PulseReading.objects.create(date=yesterday, bpm=180)
DailyNote.objects.create(date=yesterday, body="Ate well.")
recap = self.client.get("/api/tracker/recap", **self.auth_header()).json()
self.assertEqual(recap[1]["bpm"], 180)
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()
response = self.client.get(reverse("tracker:today"))
self.assertContains(response, "at least two days")
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)
response = self.client.get(reverse("tracker:today"))
response = self.client.get(reverse("tracker:recap"))
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")
class PwaTests(TrackerTestCase):
+1
View File
@@ -6,4 +6,5 @@ app_name = "tracker"
urlpatterns = [
path("today/", views.today, name="today"),
path("recap/", views.recap, name="recap"),
]
+23 -9
View File
@@ -9,7 +9,7 @@ from django.urls import reverse
from django.utils import timezone
from django.utils.dateparse import parse_date
from tracker.api import build_day, pulse_trend
from tracker.api import build_day, build_recap, pulse_trend
from tracker.models import DailyNote, DoseLog, GlobalNote, PulseReading, ScheduledDose
CHART_WIDTH = 320
@@ -103,12 +103,6 @@ def today(request):
messages.success(request, f"Saved for {day:%A %d %B}.")
return redirect(f"{reverse('tracker:today')}?date={day.isoformat()}")
readings = list(
PulseReading.objects.filter(
date__gte=timezone.localdate() - timedelta(days=90)
).order_by("date")
)
return render(
request,
"tracker/today.html",
@@ -118,8 +112,6 @@ def today(request):
"is_today": day == timezone.localdate(),
"previous_date": day - timedelta(days=1),
"next_date": day + timedelta(days=1),
"chart": build_chart(readings),
"reading_count": len(readings),
"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.
@@ -132,3 +124,25 @@ def today(request):
"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")
)
return render(
request,
"tracker/recap.html",
{
"title": "Recap",
"recap_days": build_recap(14),
"chart": build_chart(readings),
"reading_count": len(readings),
"daily_notes": DailyNote.objects.all(),
},
)