feat: add daily + global notes
This commit is contained in:
parent
d2270a737b
commit
0cfe4c7108
@ -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 %}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 -----------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
61
tracker/migrations/0003_dailynote_globalnote.py
Normal file
61
tracker/migrations/0003_dailynote_globalnote.py
Normal 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"
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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."""
|
||||
|
||||
|
||||
118
tracker/tests.py
118
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()
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user