feat: add recap page

This commit is contained in:
Loïc Gremaud 2026-07-17 17:19:26 +02:00
parent 0cfe4c7108
commit 63a5a69c67
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
6 changed files with 281 additions and 58 deletions

View File

@ -0,0 +1,129 @@
{% extends "admin/base_site.html" %}
{% block title %}Osiris — Recap{% endblock %}
{% block extrastyle %}
{{ block.super }}
<style>
/* Mobile-first, same shell as the Today page. */
#content { padding: 12px; max-width: 640px; margin: 0 auto; }
.osiris-card {
background: var(--body-bg);
border: 1px solid var(--hairline-color);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.osiris-recap { width: 100%; border-collapse: collapse; }
.osiris-recap td, .osiris-recap th {
padding: 10px 4px;
border-bottom: 1px solid var(--hairline-color);
text-align: left;
}
.osiris-recap tr:last-child td { border-bottom: none; }
.osiris-recap .status { text-align: center; width: 4em; }
.osiris-recap .bpm { text-align: right; width: 5em; }
.osiris-recap .incomplete { color: var(--error-fg, #ba2121); font-weight: 600; }
.osiris-recap a { font-weight: 600; }
.osiris-note-entry { padding: 10px 0; border-bottom: 1px solid var(--hairline-color); }
.osiris-note-entry:last-child { border-bottom: none; }
.osiris-note-entry p { margin: 4px 0 0; white-space: pre-line; }
.dose-detail { color: var(--body-quiet-color); font-size: 0.85rem; }
.osiris-stats { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 12px; }
.osiris-stats div { font-size: 0.85rem; color: var(--body-quiet-color); }
.osiris-stats strong { display: block; font-size: 1.15rem; color: var(--body-fg); }
.osiris-chart { width: 100%; height: auto; overflow: visible; }
.osiris-empty { color: var(--body-quiet-color); font-style: italic; }
</style>
{% endblock %}
{% block breadcrumbs %}{% endblock %}
{% block content %}
<div class="osiris-card">
<h2 style="margin-top:0">Doses — last 14 days</h2>
<table class="osiris-recap">
<thead>
<tr>
<th>Day</th>
<th class="status">Given</th>
<th class="bpm">Pulse</th>
</tr>
</thead>
<tbody>
{% for row in recap_days %}
<tr>
<td>
<a href="{% url 'tracker:today' %}?date={{ row.date|date:'Y-m-d' }}">{{ row.date|date:"D j M" }}</a>
{% if forloop.first %}<span class="dose-detail">today</span>{% endif %}
</td>
<td class="status">
{% if row.complete %}
{% else %}
<span class="incomplete">{{ row.taken }}/{{ row.expected }}</span>
{% endif %}
</td>
<td class="bpm">{% if row.bpm %}{{ row.bpm }}{% else %}<span class="dose-detail"></span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="dose-detail" style="margin-bottom:0">
✅ means every active dose was ticked off that day; otherwise the count given
out of expected. Tap a day to fill it in.
</p>
</div>
<div class="osiris-card">
<h2 style="margin-top:0">Pulse — last 90 days</h2>
{% if chart %}
<svg class="osiris-chart" viewBox="0 0 {{ chart.width }} {{ chart.height }}" role="img"
aria-label="Pulse from {{ chart.first_date }} to {{ chart.last_date }}">
<line x1="24" y1="24" x2="{{ chart.width|add:'-24' }}" y2="24"
stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
<line x1="24" y1="{{ chart.height|add:'-24' }}" x2="{{ chart.width|add:'-24' }}"
y2="{{ chart.height|add:'-24' }}" stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
<polyline points="{{ chart.polyline }}" fill="none" stroke="#79aec8" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>
{% for point in chart.points %}
<circle cx="{{ point.x }}" cy="{{ point.y }}" r="2" fill="#417690">
<title>{{ point.reading.date }} — {{ point.reading.bpm }} bpm</title>
</circle>
{% endfor %}
<text x="2" y="27" font-size="8" fill="currentColor" opacity="0.6">{{ chart.high }}</text>
<text x="2" y="{{ chart.height|add:'-21' }}" font-size="8" fill="currentColor" opacity="0.6">{{ chart.low }}</text>
</svg>
<div class="osiris-stats">
<div>Average<strong>{{ chart.average }} bpm</strong></div>
<div>Range<strong>{{ chart.low }}{{ chart.high }}</strong></div>
<div>Readings<strong>{{ reading_count }}</strong></div>
<div>
Trend
<strong>
{% if chart.trend > 0 %}&uarr; +{{ chart.trend }}{% elif chart.trend < 0 %}&darr; {{ chart.trend }}{% else %}&rarr; steady{% endif %}
{% if chart.trend %}<span style="font-size:0.75rem">bpm/day</span>{% endif %}
</strong>
</div>
</div>
{% else %}
<p class="osiris-empty">Record the pulse on at least two days to see the graph.</p>
{% endif %}
</div>
<div class="osiris-card">
<h2 style="margin-top:0">Daily notes</h2>
{% for note in daily_notes %}
<div class="osiris-note-entry">
<a href="{% url 'tracker:today' %}?date={{ note.date|date:'Y-m-d' }}" class="dose-detail">
{{ note.date|date:"l j F" }}
</a>
<p>{{ note.body }}</p>
</div>
{% empty %}
<p class="osiris-empty">No notes yet — write one on the Today page.</p>
{% endfor %}
</div>
<p style="text-align:center"><a href="{% url 'tracker:today' %}">&larr; Back to today</a></p>
{% endblock %}

View File

@ -54,10 +54,6 @@
border: none; border-radius: 10px; cursor: pointer; border: none; border-radius: 10px; cursor: pointer;
background: var(--button-bg); color: var(--button-fg); background: var(--button-bg); color: var(--button-fg);
} }
.osiris-stats { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 12px; }
.osiris-stats div { font-size: 0.85rem; color: var(--body-quiet-color); }
.osiris-stats strong { display: block; font-size: 1.15rem; color: var(--body-fg); }
.osiris-chart { width: 100%; height: auto; overflow: visible; }
.osiris-empty { color: var(--body-quiet-color); font-style: italic; } .osiris-empty { color: var(--body-quiet-color); font-style: italic; }
</style> </style>
{% endblock %} {% endblock %}
@ -134,43 +130,7 @@
</div> </div>
</form> </form>
<div class="osiris-card" style="margin-top:16px"> <div class="osiris-card" id="reminders" style="margin-top:16px">
<h2 style="margin-top:0">Pulse — last 90 days</h2>
{% if chart %}
<svg class="osiris-chart" viewBox="0 0 {{ chart.width }} {{ chart.height }}" role="img"
aria-label="Pulse from {{ chart.first_date }} to {{ chart.last_date }}">
<line x1="24" y1="24" x2="{{ chart.width|add:'-24' }}" y2="24"
stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
<line x1="24" y1="{{ chart.height|add:'-24' }}" x2="{{ chart.width|add:'-24' }}"
y2="{{ chart.height|add:'-24' }}" stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
<polyline points="{{ chart.polyline }}" fill="none" stroke="#79aec8" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>
{% for point in chart.points %}
<circle cx="{{ point.x }}" cy="{{ point.y }}" r="2" fill="#417690">
<title>{{ point.reading.date }} — {{ point.reading.bpm }} bpm</title>
</circle>
{% endfor %}
<text x="2" y="27" font-size="8" fill="currentColor" opacity="0.6">{{ chart.high }}</text>
<text x="2" y="{{ chart.height|add:'-21' }}" font-size="8" fill="currentColor" opacity="0.6">{{ chart.low }}</text>
</svg>
<div class="osiris-stats">
<div>Average<strong>{{ chart.average }} bpm</strong></div>
<div>Range<strong>{{ chart.low }}{{ chart.high }}</strong></div>
<div>Readings<strong>{{ reading_count }}</strong></div>
<div>
Trend
<strong>
{% if chart.trend > 0 %}&uarr; +{{ chart.trend }}{% elif chart.trend < 0 %}&darr; {{ chart.trend }}{% else %}&rarr; steady{% endif %}
{% if chart.trend %}<span style="font-size:0.75rem">bpm/day</span>{% endif %}
</strong>
</div>
</div>
{% else %}
<p class="osiris-empty">Record the pulse on at least two days to see the graph.</p>
{% endif %}
</div>
<div class="osiris-card" id="reminders">
<h2 style="margin-top:0">Reminders</h2> <h2 style="margin-top:0">Reminders</h2>
<p class="dose-detail" id="reminder-status">Checking…</p> <p class="dose-detail" id="reminder-status">Checking…</p>
<div style="display:flex; gap:8px; flex-wrap:wrap"> <div style="display:flex; gap:8px; flex-wrap:wrap">
@ -200,7 +160,10 @@
</details> </details>
</div> </div>
<p style="text-align:center"><a href="{% url 'admin:index' %}">Manage medication &amp; history &rarr;</a></p> <p style="text-align:center">
<a href="{% url 'tracker:recap' %}">Recap: doses, pulse &amp; notes &rarr;</a><br>
<a href="{% url 'admin:index' %}">Manage medication &amp; history &rarr;</a>
</p>
<script> <script>
(function () { (function () {

View File

@ -2,7 +2,7 @@ from datetime import date as date_type
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional 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.http import HttpRequest
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils import timezone from django.utils import timezone
@ -81,6 +81,17 @@ class DayOut(Schema):
note: Optional[DailyNoteOut] 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): class DoseLogIn(Schema):
scheduled_dose_id: int scheduled_dose_id: int
date: Optional[date_type] = None 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]: def pulse_trend(readings: list[PulseReading]) -> Optional[float]:
"""Least-squares slope in bpm/day: ~0 means stable, positive means climbing.""" """Least-squares slope in bpm/day: ~0 means stable, positive means climbing."""
if len(readings) < 2: 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()) 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) @router.post("/doses", response=DayOut)
def log_dose(request: HttpRequest, payload: DoseLogIn): def log_dose(request: HttpRequest, payload: DoseLogIn):
"""Tick a dose off (or untick it). Idempotent per (dose, day).""" """Tick a dose off (or untick it). Idempotent per (dose, day)."""

View File

@ -347,19 +347,73 @@ class TodayPageTests(TrackerTestCase):
self.assertContains(response, "More playful than yesterday.") self.assertContains(response, "More playful than yesterday.")
self.assertContains(response, "Vet on Friday.") 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) self.client.force_login(self.user)
today = timezone.localdate() 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 - timedelta(days=1), bpm=170)
PulseReading.objects.create(date=today, bpm=190) 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, "<polyline")
self.assertContains(response, "180.0 bpm") # average 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): class PwaTests(TrackerTestCase):

View File

@ -6,4 +6,5 @@ app_name = "tracker"
urlpatterns = [ urlpatterns = [
path("today/", views.today, name="today"), path("today/", views.today, name="today"),
path("recap/", views.recap, name="recap"),
] ]

View File

@ -9,7 +9,7 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.dateparse import parse_date 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 from tracker.models import DailyNote, DoseLog, GlobalNote, PulseReading, ScheduledDose
CHART_WIDTH = 320 CHART_WIDTH = 320
@ -103,12 +103,6 @@ def today(request):
messages.success(request, f"Saved for {day:%A %d %B}.") messages.success(request, f"Saved for {day:%A %d %B}.")
return redirect(f"{reverse('tracker:today')}?date={day.isoformat()}") 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( return render(
request, request,
"tracker/today.html", "tracker/today.html",
@ -118,8 +112,6 @@ def today(request):
"is_today": day == timezone.localdate(), "is_today": day == timezone.localdate(),
"previous_date": day - timedelta(days=1), "previous_date": day - timedelta(days=1),
"next_date": day + timedelta(days=1), "next_date": day + timedelta(days=1),
"chart": build_chart(readings),
"reading_count": len(readings),
"global_note": GlobalNote.load(), "global_note": GlobalNote.load(),
# Empty when push is not configured; the card then says so rather than # 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. # 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(), "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(),
},
)