first version

This commit is contained in:
2026-07-14 20:10:17 +02:00
commit 4040b4fdd9
32 changed files with 2176 additions and 0 deletions
View File
+48
View File
@@ -0,0 +1,48 @@
from django.contrib import admin
from tracker.models import DoseLog, Medication, PulseReading, ScheduledDose
class ScheduledDoseInline(admin.TabularInline):
"""Edit a medication's daily doses right on the medication page."""
model = ScheduledDose
extra = 1
@admin.register(Medication)
class MedicationAdmin(admin.ModelAdmin):
list_display = ["name", "is_active", "schedule_summary"]
list_filter = ["is_active"]
search_fields = ["name"]
inlines = [ScheduledDoseInline]
@admin.display(description="Schedule")
def schedule_summary(self, obj: Medication) -> str:
doses = obj.scheduled_doses.filter(is_active=True)
if not doses:
return ""
return ", ".join(
f"{d.amount} {d.get_time_of_day_display().lower()}" for d in doses
)
@admin.register(ScheduledDose)
class ScheduledDoseAdmin(admin.ModelAdmin):
list_display = ["medication", "time_of_day", "amount", "is_active"]
list_filter = ["is_active", "time_of_day", "medication"]
@admin.register(DoseLog)
class DoseLogAdmin(admin.ModelAdmin):
"""The history. Day-to-day ticking off happens on the Today page."""
list_display = ["date", "scheduled_dose", "taken", "taken_at"]
list_filter = ["taken", "date", "scheduled_dose__medication"]
date_hierarchy = "date"
@admin.register(PulseReading)
class PulseReadingAdmin(admin.ModelAdmin):
list_display = ["date", "bpm", "notes"]
date_hierarchy = "date"
+243
View File
@@ -0,0 +1,243 @@
from datetime import date as date_type
from datetime import timedelta
from typing import Optional
from django.db.models import Avg, Max, Min
from django.http import HttpRequest
from django.shortcuts import get_object_or_404
from django.utils import timezone
from ninja import Router, Schema
from ninja.errors import HttpError
from accounts.auth import AUTH
from tracker.models import DoseLog, Medication, PulseReading, ScheduledDose
router = Router(auth=AUTH)
# --- Schemas ---------------------------------------------------------------
class ScheduledDoseOut(Schema):
id: int
medication_id: int
medication_name: str
amount: str
time_of_day: int
time_of_day_label: str
class MedicationOut(Schema):
id: int
name: str
is_active: bool
notes: str
scheduled_doses: list[ScheduledDoseOut]
class DoseStatusOut(ScheduledDoseOut):
"""A scheduled dose plus whether it was given on the day being looked at."""
taken: Optional[bool] # None = not recorded yet
notes: str
class PulseOut(Schema):
date: date_type
bpm: int
notes: str
class DayOut(Schema):
date: date_type
doses: list[DoseStatusOut]
pulse: Optional[PulseOut]
class DoseLogIn(Schema):
scheduled_dose_id: int
date: Optional[date_type] = None
taken: bool = True
notes: str = ""
class PulseIn(Schema):
bpm: int
date: Optional[date_type] = None
notes: str = ""
class PulseStatsOut(Schema):
count: int
average: Optional[float]
minimum: Optional[int]
maximum: Optional[int]
trend_bpm_per_day: Optional[float]
first_date: Optional[date_type]
last_date: Optional[date_type]
# --- Helpers ---------------------------------------------------------------
def _serialize_scheduled_dose(dose: ScheduledDose) -> dict:
return {
"id": dose.id,
"medication_id": dose.medication_id,
"medication_name": dose.medication.name,
"amount": dose.amount,
"time_of_day": dose.time_of_day,
"time_of_day_label": dose.get_time_of_day_display(),
}
def build_day(day: date_type) -> dict:
"""The checklist for one day: every active dose, with its recorded state."""
# Chronological, so the day reads morning → night and groups cleanly by time.
doses = (
ScheduledDose.objects.filter(is_active=True, medication__is_active=True)
.select_related("medication")
.order_by("time_of_day", "medication__name")
)
logs = {
log.scheduled_dose_id: log
for log in DoseLog.objects.filter(date=day, scheduled_dose__in=doses)
}
return {
"date": day,
"doses": [
{
**_serialize_scheduled_dose(dose),
"taken": logs[dose.id].taken if dose.id in logs else None,
"notes": logs[dose.id].notes if dose.id in logs else "",
}
for dose in doses
],
"pulse": PulseReading.objects.filter(date=day).first(),
}
def pulse_trend(readings: list[PulseReading]) -> Optional[float]:
"""Least-squares slope in bpm/day: ~0 means stable, positive means climbing."""
if len(readings) < 2:
return None
origin = readings[0].date
xs = [(r.date - origin).days for r in readings]
ys = [r.bpm for r in readings]
n = len(xs)
mean_x = sum(xs) / n
mean_y = sum(ys) / n
variance = sum((x - mean_x) ** 2 for x in xs)
if variance == 0:
return None
covariance = sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys))
return round(covariance / variance, 3)
# --- Medications -----------------------------------------------------------
@router.get("/medications", response=list[MedicationOut])
def list_medications(request: HttpRequest, include_inactive: bool = False):
"""The medication plan. Edit it in the Django admin."""
medications = Medication.objects.prefetch_related("scheduled_doses__medication")
if not include_inactive:
medications = medications.filter(is_active=True)
return [
{
"id": med.id,
"name": med.name,
"is_active": med.is_active,
"notes": med.notes,
"scheduled_doses": [
_serialize_scheduled_dose(dose)
for dose in med.scheduled_doses.all()
if dose.is_active or include_inactive
],
}
for med in medications
]
# --- Daily doses -----------------------------------------------------------
@router.get("/day", response=DayOut)
def get_day(request: HttpRequest, date: Optional[date_type] = None):
"""Everything for one day (defaults to today): the dose checklist and the pulse."""
return build_day(date or timezone.localdate())
@router.post("/doses", response=DayOut)
def log_dose(request: HttpRequest, payload: DoseLogIn):
"""Tick a dose off (or untick it). Idempotent per (dose, day)."""
day = payload.date or timezone.localdate()
dose = get_object_or_404(ScheduledDose, id=payload.scheduled_dose_id)
log, _ = DoseLog.objects.get_or_create(
scheduled_dose=dose, date=day, defaults={"taken": payload.taken}
)
log.taken = payload.taken
log.notes = payload.notes
log.save()
return build_day(day)
# --- Pulse -----------------------------------------------------------------
@router.get("/pulse", response=list[PulseOut])
def list_pulse(request: HttpRequest, days: int = 90):
"""Readings for the graph, oldest first."""
since = timezone.localdate() - timedelta(days=days)
return PulseReading.objects.filter(date__gte=since).order_by("date")
@router.post("/pulse", response=PulseOut)
def record_pulse(request: HttpRequest, payload: PulseIn):
"""One reading per day: posting twice for the same day overwrites it."""
if not 20 <= payload.bpm <= 400:
raise HttpError(422, "A pulse of that value is not plausible for a cat.")
day = payload.date or timezone.localdate()
reading, _ = PulseReading.objects.update_or_create(
date=day, defaults={"bpm": payload.bpm, "notes": payload.notes}
)
return reading
@router.get("/pulse/stats", response=PulseStatsOut)
def pulse_stats(request: HttpRequest, days: int = 90):
"""Is the pulse steady or drifting? `trend_bpm_per_day` is the slope.
Declared before /pulse/{date}, which would otherwise match "stats" first.
"""
since = timezone.localdate() - timedelta(days=days)
readings = list(PulseReading.objects.filter(date__gte=since).order_by("date"))
aggregates = PulseReading.objects.filter(date__gte=since).aggregate(
average=Avg("bpm"), minimum=Min("bpm"), maximum=Max("bpm")
)
return {
"count": len(readings),
"average": round(aggregates["average"], 1) if aggregates["average"] else None,
"minimum": aggregates["minimum"],
"maximum": aggregates["maximum"],
"trend_bpm_per_day": pulse_trend(readings),
"first_date": readings[0].date if readings else None,
"last_date": readings[-1].date if readings else None,
}
@router.delete("/pulse/{date}")
def delete_pulse(request: HttpRequest, date: date_type):
get_object_or_404(PulseReading, date=date).delete()
return {"detail": "Deleted."}
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class TrackerConfig(AppConfig):
name = "tracker"
+157
View File
@@ -0,0 +1,157 @@
# Generated by Django 6.0.7 on 2026-07-14 18:04
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Medication",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100, unique=True)),
(
"is_active",
models.BooleanField(
default=True,
help_text="Uncheck when the treatment ends, instead of deleting.",
),
),
("notes", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
options={
"ordering": ["name"],
},
),
migrations.CreateModel(
name="PulseReading",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"date",
models.DateField(
default=django.utils.timezone.localdate, unique=True
),
),
(
"bpm",
models.PositiveSmallIntegerField(help_text="Beats per minute."),
),
("notes", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
options={
"ordering": ["-date"],
},
),
migrations.CreateModel(
name="ScheduledDose",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"time_of_day",
models.IntegerField(
choices=[
(1, "Morning"),
(2, "Noon"),
(3, "Evening"),
(4, "Night"),
]
),
),
(
"amount",
models.CharField(
default="1/4",
help_text='Free text, e.g. "1/4", "1", "5 mg".',
max_length=20,
),
),
("is_active", models.BooleanField(default=True)),
(
"medication",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="scheduled_doses",
to="tracker.medication",
),
),
],
options={
"ordering": ["medication__name", "time_of_day"],
},
),
migrations.CreateModel(
name="DoseLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date", models.DateField(default=django.utils.timezone.localdate)),
("taken", models.BooleanField(default=True)),
("taken_at", models.DateTimeField(blank=True, null=True)),
("notes", models.TextField(blank=True)),
(
"scheduled_dose",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="logs",
to="tracker.scheduleddose",
),
),
],
options={
"ordering": ["-date", "scheduled_dose__time_of_day"],
},
),
migrations.AddConstraint(
model_name="scheduleddose",
constraint=models.UniqueConstraint(
fields=("medication", "time_of_day"),
name="unique_medication_time_of_day",
),
),
migrations.AddConstraint(
model_name="doselog",
constraint=models.UniqueConstraint(
fields=("scheduled_dose", "date"), name="unique_dose_per_day"
),
),
]
View File
+107
View File
@@ -0,0 +1,107 @@
from django.db import models
from django.utils import timezone
class TimeOfDay(models.IntegerChoices):
"""Integer-backed so that ordering by this field is chronological."""
MORNING = 1, "Morning"
NOON = 2, "Noon"
EVENING = 3, "Evening"
NIGHT = 4, "Night"
class Medication(models.Model):
"""A drug Osiris is on, e.g. "Vetmedin"."""
name = models.CharField(max_length=100, unique=True)
is_active = models.BooleanField(
default=True, help_text="Uncheck when the treatment ends, instead of deleting."
)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class ScheduledDose(models.Model):
"""One recurring dose of a medication, e.g. "Vetmedin, 1/4, every morning".
A medication taken morning and evening has two of these.
"""
medication = models.ForeignKey(
Medication, on_delete=models.CASCADE, related_name="scheduled_doses"
)
time_of_day = models.IntegerField(choices=TimeOfDay.choices)
amount = models.CharField(
max_length=20, default="1/4", help_text='Free text, e.g. "1/4", "1", "5 mg".'
)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["medication__name", "time_of_day"]
constraints = [
models.UniqueConstraint(
fields=["medication", "time_of_day"],
name="unique_medication_time_of_day",
)
]
def __str__(self):
return f"{self.medication.name}{self.amount} ({self.get_time_of_day_display().lower()})"
class DoseLog(models.Model):
"""Whether a given scheduled dose was actually given on a given day.
A missing row means "not recorded"; a row with taken=False means "explicitly
marked as not given", so unchecking a box is distinguishable from never
having touched it.
"""
scheduled_dose = models.ForeignKey(
ScheduledDose, on_delete=models.CASCADE, related_name="logs"
)
date = models.DateField(default=timezone.localdate)
taken = models.BooleanField(default=True)
taken_at = models.DateTimeField(null=True, blank=True)
notes = models.TextField(blank=True)
class Meta:
ordering = ["-date", "scheduled_dose__time_of_day"]
constraints = [
models.UniqueConstraint(
fields=["scheduled_dose", "date"], name="unique_dose_per_day"
)
]
def __str__(self):
state = "given" if self.taken else "missed"
return f"{self.date}{self.scheduled_dose}{state}"
def save(self, *args, **kwargs):
if self.taken and self.taken_at is None:
self.taken_at = timezone.now()
if not self.taken:
self.taken_at = None
return super().save(*args, **kwargs)
class PulseReading(models.Model):
"""One heart-rate measurement per day, in beats per minute."""
date = models.DateField(default=timezone.localdate, unique=True)
bpm = models.PositiveSmallIntegerField(help_text="Beats per minute.")
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-date"]
def __str__(self):
return f"{self.date}{self.bpm} bpm"
+231
View File
@@ -0,0 +1,231 @@
from datetime import timedelta
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from accounts.models import User
from tracker.models import DoseLog, Medication, PulseReading, ScheduledDose, TimeOfDay
class TrackerTestCase(TestCase):
def setUp(self):
self.user = User.objects.create_superuser("loic", password="hunter2hunter2")
# Medication 1: 1/4 every morning.
self.vetmedin = Medication.objects.create(name="Vetmedin")
self.morning_only = ScheduledDose.objects.create(
medication=self.vetmedin, time_of_day=TimeOfDay.MORNING, amount="1/4"
)
# Medication 2: 1/4 morning and 1/4 evening.
self.fortekor = Medication.objects.create(name="Fortekor")
self.morning = ScheduledDose.objects.create(
medication=self.fortekor, time_of_day=TimeOfDay.MORNING, amount="1/4"
)
self.evening = ScheduledDose.objects.create(
medication=self.fortekor, time_of_day=TimeOfDay.EVENING, amount="1/4"
)
def token(self) -> str:
response = self.client.post(
"/api/auth/login",
{"username": "loic", "password": "hunter2hunter2"},
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
return response.json()["token"]
def auth_header(self) -> dict:
return {"HTTP_AUTHORIZATION": f"Bearer {self.token()}"}
class AuthTests(TrackerTestCase):
def test_login_rejects_a_bad_password(self):
response = self.client.post(
"/api/auth/login",
{"username": "loic", "password": "wrong"},
content_type="application/json",
)
self.assertEqual(response.status_code, 401)
def test_endpoints_require_authentication(self):
self.assertEqual(self.client.get("/api/tracker/day").status_code, 401)
def test_health_is_public(self):
self.assertEqual(self.client.get("/api/health").status_code, 200)
class DoseApiTests(TrackerTestCase):
def test_day_lists_every_scheduled_dose_unrecorded(self):
response = self.client.get("/api/tracker/day", **self.auth_header())
self.assertEqual(response.status_code, 200)
doses = response.json()["doses"]
self.assertEqual(len(doses), 3)
self.assertTrue(all(dose["taken"] is None for dose in doses))
# Morning doses come before the evening one.
self.assertEqual([d["time_of_day"] for d in doses], [1, 1, 3])
def test_logging_a_dose_marks_only_that_dose(self):
response = self.client.post(
"/api/tracker/doses",
{"scheduled_dose_id": self.morning.id, "taken": True},
content_type="application/json",
**self.auth_header(),
)
self.assertEqual(response.status_code, 200)
doses = {d["id"]: d["taken"] for d in response.json()["doses"]}
self.assertIs(doses[self.morning.id], True)
self.assertIsNone(doses[self.evening.id])
self.assertIsNone(doses[self.morning_only.id])
def test_logging_the_same_dose_twice_updates_in_place(self):
for taken in (True, False):
self.client.post(
"/api/tracker/doses",
{"scheduled_dose_id": self.morning.id, "taken": taken},
content_type="application/json",
**self.auth_header(),
)
log = DoseLog.objects.get(scheduled_dose=self.morning)
self.assertFalse(log.taken)
self.assertIsNone(log.taken_at)
self.assertEqual(DoseLog.objects.count(), 1)
def test_a_dose_can_be_logged_for_a_past_day(self):
yesterday = timezone.localdate() - timedelta(days=1)
self.client.post(
"/api/tracker/doses",
{
"scheduled_dose_id": self.evening.id,
"date": yesterday.isoformat(),
"taken": True,
},
content_type="application/json",
**self.auth_header(),
)
self.assertTrue(DoseLog.objects.filter(date=yesterday, taken=True).exists())
self.assertFalse(DoseLog.objects.filter(date=timezone.localdate()).exists())
def test_inactive_medication_drops_out_of_the_day(self):
self.fortekor.is_active = False
self.fortekor.save()
response = self.client.get("/api/tracker/day", **self.auth_header())
self.assertEqual(len(response.json()["doses"]), 1)
class PulseApiTests(TrackerTestCase):
def test_recording_the_pulse_twice_in_a_day_overwrites(self):
for bpm in (180, 192):
response = self.client.post(
"/api/tracker/pulse",
{"bpm": bpm},
content_type="application/json",
**self.auth_header(),
)
self.assertEqual(response.status_code, 200)
self.assertEqual(PulseReading.objects.count(), 1)
self.assertEqual(PulseReading.objects.get().bpm, 192)
def test_an_implausible_pulse_is_rejected(self):
response = self.client.post(
"/api/tracker/pulse",
{"bpm": 5},
content_type="application/json",
**self.auth_header(),
)
self.assertEqual(response.status_code, 422)
def test_stats_report_a_rising_trend(self):
today = timezone.localdate()
for offset, bpm in enumerate([160, 170, 180, 190]):
PulseReading.objects.create(
date=today - timedelta(days=3 - offset), bpm=bpm
)
stats = self.client.get("/api/tracker/pulse/stats", **self.auth_header()).json()
self.assertEqual(stats["count"], 4)
self.assertEqual(stats["minimum"], 160)
self.assertEqual(stats["maximum"], 190)
self.assertEqual(stats["average"], 175.0)
self.assertEqual(stats["trend_bpm_per_day"], 10.0)
def test_stats_are_empty_without_readings(self):
stats = self.client.get("/api/tracker/pulse/stats", **self.auth_header()).json()
self.assertEqual(stats["count"], 0)
self.assertIsNone(stats["average"])
self.assertIsNone(stats["trend_bpm_per_day"])
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"])
def test_the_page_lists_the_doses(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")
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",
},
)
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_the_graph_appears_once_there_are_two_readings(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)
response = self.client.get(reverse("tracker:today"))
self.assertContains(response, "<polyline")
self.assertContains(response, "180.0 bpm") # average
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from tracker import views
app_name = "tracker"
urlpatterns = [
path("today/", views.today, name="today"),
]
+107
View File
@@ -0,0 +1,107 @@
from datetime import timedelta
from typing import Optional
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 tracker.api import build_day, pulse_trend
from tracker.models import DoseLog, PulseReading, ScheduledDose
CHART_WIDTH = 320
CHART_HEIGHT = 140
CHART_PADDING = 24
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
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
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,
}
@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()
if request.method == "POST":
day = parse_date(request.POST.get("date", "")) or timezone.localdate()
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()
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",
{
"title": "Today",
"day": build_day(day),
"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),
},
)