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

+ + +
+ +
+ {% csrf_token %} + +
+

Shared note

+ + + {% if global_note.body %} +

Last updated {{ global_note.updated_at|date:"D j M, H:i" }}

+ {% endif %} + +
+
+

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,