feat: add recap page
This commit is contained in:
parent
0cfe4c7108
commit
63a5a69c67
129
templates/tracker/recap.html
Normal file
129
templates/tracker/recap.html
Normal 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 %}↑ +{{ chart.trend }}{% elif chart.trend < 0 %}↓ {{ chart.trend }}{% else %}→ 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' %}">← Back to today</a></p>
|
||||
{% endblock %}
|
||||
@ -54,10 +54,6 @@
|
||||
border: none; border-radius: 10px; cursor: pointer;
|
||||
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; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@ -134,43 +130,7 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="osiris-card" 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 %}↑ +{{ chart.trend }}{% elif chart.trend < 0 %}↓ {{ chart.trend }}{% else %}→ 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">
|
||||
<div class="osiris-card" id="reminders" style="margin-top:16px">
|
||||
<h2 style="margin-top:0">Reminders</h2>
|
||||
<p class="dose-detail" id="reminder-status">Checking…</p>
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap">
|
||||
@ -200,7 +160,10 @@
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<p style="text-align:center"><a href="{% url 'admin:index' %}">Manage medication & history →</a></p>
|
||||
<p style="text-align:center">
|
||||
<a href="{% url 'tracker:recap' %}">Recap: doses, pulse & notes →</a><br>
|
||||
<a href="{% url 'admin:index' %}">Manage medication & history →</a>
|
||||
</p>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
@ -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)."""
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -6,4 +6,5 @@ app_name = "tracker"
|
||||
|
||||
urlpatterns = [
|
||||
path("today/", views.today, name="today"),
|
||||
path("recap/", views.recap, name="recap"),
|
||||
]
|
||||
|
||||
@ -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(),
|
||||
},
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user