feat: add daily + global notes

This commit is contained in:
Loïc Gremaud 2026-07-17 14:43:05 +02:00
parent d2270a737b
commit 0cfe4c7108
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
7 changed files with 349 additions and 3 deletions

View File

@ -40,6 +40,11 @@
color: var(--body-quiet-color); margin: 16px 0 4px; color: var(--body-quiet-color); margin: 16px 0 4px;
} }
.osiris-note textarea {
width: 100%; min-height: 90px; padding: 12px; font: inherit;
border-radius: 8px; border: 1px solid var(--border-color);
background: var(--body-bg); color: var(--body-fg); resize: vertical;
}
.osiris-pulse input[type="number"] { .osiris-pulse input[type="number"] {
font-size: 1.6rem; width: 100%; padding: 12px; font-size: 1.6rem; width: 100%; padding: 12px;
border-radius: 8px; border: 1px solid var(--border-color); border-radius: 8px; border: 1px solid var(--border-color);
@ -106,9 +111,29 @@
placeholder="e.g. 180" value="{{ day.pulse.bpm|default_if_none:'' }}"> placeholder="e.g. 180" value="{{ day.pulse.bpm|default_if_none:'' }}">
</div> </div>
<div class="osiris-card osiris-note">
<h2 style="margin-top:0">Note for the day</h2>
<label for="note" class="dose-detail">Appetite, behaviour, anything worth remembering (leave empty to clear)</label>
<textarea id="note" name="note" placeholder="e.g. Ate well, more playful than yesterday">{{ day.note.body|default:'' }}</textarea>
</div>
<button type="submit" class="osiris-save">Save {{ day.date|date:"D j M" }}</button> <button type="submit" class="osiris-save">Save {{ day.date|date:"D j M" }}</button>
</form> </form>
<form method="post" style="margin-top:16px">
{% csrf_token %}
<input type="hidden" name="date" value="{{ day.date|date:'Y-m-d' }}">
<div class="osiris-card osiris-note">
<h2 style="margin-top:0">Shared note</h2>
<label for="global_note" class="dose-detail">Instructions and things to do — one note for everyone, on every day</label>
<textarea id="global_note" name="global_note" placeholder="e.g. Vet appointment Friday — bring the pulse log">{{ global_note.body }}</textarea>
{% if global_note.body %}
<p class="dose-detail">Last updated {{ global_note.updated_at|date:"D j M, H:i" }}</p>
{% endif %}
<button type="submit" class="osiris-save">Save shared note</button>
</div>
</form>
<div class="osiris-card" style="margin-top:16px"> <div class="osiris-card" style="margin-top:16px">
<h2 style="margin-top:0">Pulse — last 90 days</h2> <h2 style="margin-top:0">Pulse — last 90 days</h2>
{% if chart %} {% if chart %}

View File

@ -1,7 +1,9 @@
from django.contrib import admin from django.contrib import admin
from tracker.models import ( from tracker.models import (
DailyNote,
DoseLog, DoseLog,
GlobalNote,
Medication, Medication,
PulseReading, PulseReading,
ReminderSent, ReminderSent,
@ -66,3 +68,29 @@ class DoseLogAdmin(admin.ModelAdmin):
class PulseReadingAdmin(admin.ModelAdmin): class PulseReadingAdmin(admin.ModelAdmin):
list_display = ["date", "bpm", "notes"] list_display = ["date", "bpm", "notes"]
date_hierarchy = "date" date_hierarchy = "date"
@admin.register(DailyNote)
class DailyNoteAdmin(admin.ModelAdmin):
"""The journal. Day-to-day writing happens on the Today page."""
list_display = ["date", "preview", "updated_at"]
date_hierarchy = "date"
search_fields = ["body"]
@admin.display(description="Note")
def preview(self, obj: DailyNote) -> str:
return obj.body[:80]
@admin.register(GlobalNote)
class GlobalNoteAdmin(admin.ModelAdmin):
"""The one shared note. It can be edited here, but never added or deleted."""
list_display = ["__str__", "updated_at"]
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False

View File

@ -1,5 +1,5 @@
from datetime import date as date_type from datetime import date as date_type
from datetime import 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, Max, Min
@ -10,7 +10,14 @@ from ninja import Router, Schema
from ninja.errors import HttpError from ninja.errors import HttpError
from accounts.auth import AUTH from accounts.auth import AUTH
from tracker.models import DoseLog, Medication, PulseReading, ScheduledDose from tracker.models import (
DailyNote,
DoseLog,
GlobalNote,
Medication,
PulseReading,
ScheduledDose,
)
router = Router(auth=AUTH) router = Router(auth=AUTH)
@ -48,10 +55,30 @@ class PulseOut(Schema):
notes: str notes: str
class DailyNoteOut(Schema):
date: date_type
body: str
class DailyNoteIn(Schema):
body: str
date: Optional[date_type] = None
class GlobalNoteOut(Schema):
body: str
updated_at: Optional[datetime] = None
class GlobalNoteIn(Schema):
body: str
class DayOut(Schema): class DayOut(Schema):
date: date_type date: date_type
doses: list[DoseStatusOut] doses: list[DoseStatusOut]
pulse: Optional[PulseOut] pulse: Optional[PulseOut]
note: Optional[DailyNoteOut]
class DoseLogIn(Schema): class DoseLogIn(Schema):
@ -116,6 +143,7 @@ def build_day(day: date_type) -> dict:
for dose in doses for dose in doses
], ],
"pulse": PulseReading.objects.filter(date=day).first(), "pulse": PulseReading.objects.filter(date=day).first(),
"note": DailyNote.objects.filter(date=day).first(),
} }
@ -190,6 +218,37 @@ def log_dose(request: HttpRequest, payload: DoseLogIn):
return build_day(day) return build_day(day)
# --- Notes -----------------------------------------------------------------
@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."""
day = payload.date or timezone.localdate()
if payload.body.strip():
DailyNote.objects.update_or_create(date=day, defaults={"body": payload.body})
else:
DailyNote.objects.filter(date=day).delete()
return build_day(day)
@router.get("/notes/global", response=GlobalNoteOut)
def get_global_note(request: HttpRequest):
"""The single shared note: standing instructions and things to do."""
return GlobalNote.load()
@router.put("/notes/global", response=GlobalNoteOut)
def save_global_note(request: HttpRequest, payload: GlobalNoteIn):
"""Replace the shared note's content. Last write wins."""
note = GlobalNote.load()
note.body = payload.body
note.save(update_fields=["body", "updated_at"])
return note
# --- Pulse ----------------------------------------------------------------- # --- Pulse -----------------------------------------------------------------

View File

@ -0,0 +1,61 @@
# Generated by Django 6.0.7 on 2026-07-17 11:35
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("tracker", "0002_scheduleddose_reminder_time_remindersent"),
]
operations = [
migrations.CreateModel(
name="DailyNote",
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
),
),
("body", models.TextField(blank=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["-date"],
},
),
migrations.CreateModel(
name="GlobalNote",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("body", models.TextField(blank=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"constraints": [
models.CheckConstraint(
condition=models.Q(("id", 1)), name="single_global_note"
)
],
},
),
]

View File

@ -122,6 +122,44 @@ class ReminderSent(models.Model):
return f"{self.date}{self.scheduled_dose}" return f"{self.date}{self.scheduled_dose}"
class DailyNote(models.Model):
"""Free-form journal entry for one day: appetite, behaviour, how Osiris is doing."""
date = models.DateField(default=timezone.localdate, unique=True)
body = models.TextField(blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-date"]
def __str__(self):
return f"{self.date}{self.body[:40]}"
class GlobalNote(models.Model):
"""The single shared note: standing instructions and things to do.
There is exactly one row, shared by everyone; the constraint pins its pk
so a second one cannot be created even by accident.
"""
body = models.TextField(blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.CheckConstraint(condition=models.Q(id=1), name="single_global_note")
]
def __str__(self):
return "Global note"
@classmethod
def load(cls) -> "GlobalNote":
note, _ = cls.objects.get_or_create(pk=1)
return note
class PulseReading(models.Model): class PulseReading(models.Model):
"""One heart-rate measurement per day, in beats per minute.""" """One heart-rate measurement per day, in beats per minute."""

View File

@ -12,7 +12,9 @@ from pywebpush import WebPushException
from accounts.models import PushSubscription, User from accounts.models import PushSubscription, User
from tracker.models import ( from tracker.models import (
DailyNote,
DoseLog, DoseLog,
GlobalNote,
Medication, Medication,
PulseReading, PulseReading,
ReminderSent, ReminderSent,
@ -178,6 +180,74 @@ class PulseApiTests(TrackerTestCase):
self.assertIsNone(stats["trend_bpm_per_day"]) self.assertIsNone(stats["trend_bpm_per_day"])
class NoteApiTests(TrackerTestCase):
def test_the_day_includes_its_note(self):
DailyNote.objects.create(body="Ate well.")
response = self.client.get("/api/tracker/day", **self.auth_header())
self.assertEqual(response.json()["note"]["body"], "Ate well.")
def test_saving_a_daily_note_twice_overwrites(self):
for body in ("Sleepy.", "Playful after all."):
response = self.client.post(
"/api/tracker/notes/daily",
{"body": body},
content_type="application/json",
**self.auth_header(),
)
self.assertEqual(response.status_code, 200)
self.assertEqual(DailyNote.objects.count(), 1)
self.assertEqual(DailyNote.objects.get().body, "Playful after all.")
def test_a_daily_note_can_target_a_past_day(self):
yesterday = timezone.localdate() - timedelta(days=1)
self.client.post(
"/api/tracker/notes/daily",
{"body": "Rough night.", "date": yesterday.isoformat()},
content_type="application/json",
**self.auth_header(),
)
self.assertTrue(DailyNote.objects.filter(date=yesterday).exists())
self.assertFalse(DailyNote.objects.filter(date=timezone.localdate()).exists())
def test_an_empty_body_removes_the_daily_note(self):
DailyNote.objects.create(body="Old news.")
self.client.post(
"/api/tracker/notes/daily",
{"body": " "},
content_type="application/json",
**self.auth_header(),
)
self.assertEqual(DailyNote.objects.count(), 0)
def test_the_global_note_exists_without_ever_being_created(self):
response = self.client.get("/api/tracker/notes/global", **self.auth_header())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["body"], "")
def test_the_global_note_is_shared_and_unique(self):
response = self.client.put(
"/api/tracker/notes/global",
{"body": "Vet on Friday."},
content_type="application/json",
**self.auth_header(),
)
self.assertEqual(response.status_code, 200)
other = User.objects.create_superuser("marie", password="hunter2hunter2")
self.client.force_login(other)
response = self.client.get("/api/tracker/notes/global")
self.assertEqual(response.json()["body"], "Vet on Friday.")
self.assertEqual(GlobalNote.objects.count(), 1)
class TodayPageTests(TrackerTestCase): class TodayPageTests(TrackerTestCase):
def test_the_page_requires_login(self): def test_the_page_requires_login(self):
response = self.client.get(reverse("tracker:today")) response = self.client.get(reverse("tracker:today"))
@ -229,6 +299,54 @@ class TodayPageTests(TrackerTestCase):
self.assertFalse(PulseReading.objects.filter(date=today).exists()) 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.")
def test_the_graph_appears_once_there_are_two_readings(self): def test_the_graph_appears_once_there_are_two_readings(self):
self.client.force_login(self.user) self.client.force_login(self.user)
today = timezone.localdate() today = timezone.localdate()

View File

@ -10,7 +10,7 @@ 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, pulse_trend
from tracker.models import DoseLog, PulseReading, ScheduledDose from tracker.models import DailyNote, DoseLog, GlobalNote, PulseReading, ScheduledDose
CHART_WIDTH = 320 CHART_WIDTH = 320
CHART_HEIGHT = 140 CHART_HEIGHT = 140
@ -62,6 +62,16 @@ def today(request):
if request.method == "POST": if request.method == "POST":
day = parse_date(request.POST.get("date", "")) or timezone.localdate() 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) doses = ScheduledDose.objects.filter(is_active=True, medication__is_active=True)
for dose in doses: for dose in doses:
@ -84,6 +94,12 @@ def today(request):
# Clearing the field removes the reading for that day. # Clearing the field removes the reading for that day.
PulseReading.objects.filter(date=day).delete() 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}.") 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()}")
@ -104,6 +120,7 @@ def today(request):
"next_date": day + timedelta(days=1), "next_date": day + timedelta(days=1),
"chart": build_chart(readings), "chart": build_chart(readings),
"reading_count": len(readings), "reading_count": len(readings),
"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.
"vapid_public_key": settings.VAPID_PUBLIC_KEY, "vapid_public_key": settings.VAPID_PUBLIC_KEY,