diff --git a/templates/tracker/today.html b/templates/tracker/today.html
index ea1e225..8a2c38c 100644
--- a/templates/tracker/today.html
+++ b/templates/tracker/today.html
@@ -40,6 +40,11 @@
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"] {
font-size: 1.6rem; width: 100%; padding: 12px;
border-radius: 8px; border: 1px solid var(--border-color);
@@ -106,9 +111,29 @@
placeholder="e.g. 180" value="{{ day.pulse.bpm|default_if_none:'' }}">
+
+
Note for the day
+
+
+
+
+
+
Pulse — last 90 days
{% if chart %}
diff --git a/tracker/admin.py b/tracker/admin.py
index 310c7cd..cfa0439 100644
--- a/tracker/admin.py
+++ b/tracker/admin.py
@@ -1,7 +1,9 @@
from django.contrib import admin
from tracker.models import (
+ DailyNote,
DoseLog,
+ GlobalNote,
Medication,
PulseReading,
ReminderSent,
@@ -66,3 +68,29 @@ class DoseLogAdmin(admin.ModelAdmin):
class PulseReadingAdmin(admin.ModelAdmin):
list_display = ["date", "bpm", "notes"]
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
diff --git a/tracker/api.py b/tracker/api.py
index da2daa8..c5e212d 100644
--- a/tracker/api.py
+++ b/tracker/api.py
@@ -1,5 +1,5 @@
from datetime import date as date_type
-from datetime import timedelta
+from datetime import datetime, timedelta
from typing import Optional
from django.db.models import Avg, Max, Min
@@ -10,7 +10,14 @@ from ninja import Router, Schema
from ninja.errors import HttpError
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)
@@ -48,10 +55,30 @@ class PulseOut(Schema):
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):
date: date_type
doses: list[DoseStatusOut]
pulse: Optional[PulseOut]
+ note: Optional[DailyNoteOut]
class DoseLogIn(Schema):
@@ -116,6 +143,7 @@ def build_day(day: date_type) -> dict:
for dose in doses
],
"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)
+# --- 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 -----------------------------------------------------------------
diff --git a/tracker/migrations/0003_dailynote_globalnote.py b/tracker/migrations/0003_dailynote_globalnote.py
new file mode 100644
index 0000000..25e53b3
--- /dev/null
+++ b/tracker/migrations/0003_dailynote_globalnote.py
@@ -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"
+ )
+ ],
+ },
+ ),
+ ]
diff --git a/tracker/models.py b/tracker/models.py
index 66dd270..ecc61bf 100644
--- a/tracker/models.py
+++ b/tracker/models.py
@@ -122,6 +122,44 @@ class ReminderSent(models.Model):
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):
"""One heart-rate measurement per day, in beats per minute."""
diff --git a/tracker/tests.py b/tracker/tests.py
index a31a307..4d7c9df 100644
--- a/tracker/tests.py
+++ b/tracker/tests.py
@@ -12,7 +12,9 @@ from pywebpush import WebPushException
from accounts.models import PushSubscription, User
from tracker.models import (
+ DailyNote,
DoseLog,
+ GlobalNote,
Medication,
PulseReading,
ReminderSent,
@@ -178,6 +180,74 @@ class PulseApiTests(TrackerTestCase):
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):
def test_the_page_requires_login(self):
response = self.client.get(reverse("tracker:today"))
@@ -229,6 +299,54 @@ class TodayPageTests(TrackerTestCase):
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):
self.client.force_login(self.user)
today = timezone.localdate()
diff --git a/tracker/views.py b/tracker/views.py
index 71717f3..9365f3b 100644
--- a/tracker/views.py
+++ b/tracker/views.py
@@ -10,7 +10,7 @@ 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
+from tracker.models import DailyNote, DoseLog, GlobalNote, PulseReading, ScheduledDose
CHART_WIDTH = 320
CHART_HEIGHT = 140
@@ -62,6 +62,16 @@ def today(request):
if request.method == "POST":
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)
for dose in doses:
@@ -84,6 +94,12 @@ def today(request):
# Clearing the field removes the reading for that day.
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}.")
return redirect(f"{reverse('tracker:today')}?date={day.isoformat()}")
@@ -104,6 +120,7 @@ def today(request):
"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.
"vapid_public_key": settings.VAPID_PUBLIC_KEY,