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;
}
.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:'' }}">
</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>
</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">
<h2 style="margin-top:0">Pulse — last 90 days</h2>
{% if chart %}

View File

@ -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

View File

@ -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 -----------------------------------------------------------------

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}"
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."""

View File

@ -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()

View File

@ -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,