feat: frontend using django-vite + blabla

This commit is contained in:
2026-07-17 18:02:42 +02:00
parent 63a5a69c67
commit c06b184a2d
48 changed files with 13896 additions and 885 deletions
-109
View File
@@ -1,109 +0,0 @@
// Served from / (not /static/) so its scope covers /admin/today/.
// Bump on any change here or to the cached pages: activate() drops older caches,
// which is what stops a stale Today page surviving a deploy.
const CACHE = "osiris-v2";
self.addEventListener("install", (event) => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))
)
.then(() => self.clients.claim())
);
});
self.addEventListener("push", (event) => {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch (e) {
payload = { title: "Osiris", body: event.data ? event.data.text() : "" };
}
event.waitUntil(
self.registration.showNotification(payload.title || "Osiris", {
body: payload.body || "",
icon: "/static/icons/icon-192.png",
badge: "/static/icons/icon-192.png",
// Same tag replaces the previous notification instead of stacking.
tag: payload.tag || "osiris",
renotify: true,
requireInteraction: true, // A dose reminder should wait, not fade away.
data: { url: payload.url || "/admin/today/" },
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = (event.notification.data && event.notification.data.url) || "/admin/today/";
// Focus the app if it is already open rather than opening a second window.
event.waitUntil(
self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((windows) => {
for (const client of windows) {
if (client.url.includes(url) && "focus" in client) return client.focus();
}
return self.clients.openWindow(url);
})
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
// Never touch writes or the API: a stale or replayed dose would be a lie.
if (request.method !== "GET" || new URL(request.url).pathname.startsWith("/api/")) {
return;
}
// Static assets change rarely: serve from cache, refresh in the background.
if (new URL(request.url).pathname.startsWith("/static/")) {
event.respondWith(
caches.open(CACHE).then(async (cache) => {
const cached = await cache.match(request);
const network = fetch(request)
.then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
})
.catch(() => cached);
return cached || network;
})
);
return;
}
// Pages: always prefer the live version, fall back to the last one seen so the
// app still opens (read-only) with no signal.
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const copy = response.clone();
caches.open(CACHE).then((cache) => cache.put(request, copy));
}
return response;
})
.catch(async () => {
const cached = await caches.match(request);
return (
cached ||
new Response(
"<!doctype html><meta name='viewport' content='width=device-width,initial-scale=1'>" +
"<body style='font-family:system-ui;background:#202a35;color:#eee;padding:2rem;text-align:center'>" +
"<h1>Offline</h1><p>Osiris needs a connection to load. Try again once you're back online.</p>",
{ headers: { "Content-Type": "text/html; charset=utf-8" } }
)
);
})
);
});
+24
View File
@@ -0,0 +1,24 @@
{% load django_vite static %}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Osiris</title>
<link rel="manifest" href="{% url 'manifest' %}">
<meta name="theme-color" content="#202a35">
<meta name="mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" href="{% static 'icons/icon-192.png' %}">
<link rel="icon" href="{% static 'icons/icon-192.png' %}">
{% vite_hmr_client %}
{% vite_asset 'src/main.ts' %}
</head>
<body>
<div id="app"></div>
{{ config|json_script:"osiris-config" }}
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => navigator.serviceWorker.register("/sw.js"));
}
</script>
</body>
</html>
-129
View File
@@ -1,129 +0,0 @@
{% extends "admin/base_site.html" %}
{% block title %}Osiris — Recap{% endblock %}
{% block extrastyle %}
{{ block.super }}
<style>
/* Mobile-first, same shell as the Today page. */
#content { padding: 12px; max-width: 640px; margin: 0 auto; }
.osiris-card {
background: var(--body-bg);
border: 1px solid var(--hairline-color);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.osiris-recap { width: 100%; border-collapse: collapse; }
.osiris-recap td, .osiris-recap th {
padding: 10px 4px;
border-bottom: 1px solid var(--hairline-color);
text-align: left;
}
.osiris-recap tr:last-child td { border-bottom: none; }
.osiris-recap .status { text-align: center; width: 4em; }
.osiris-recap .bpm { text-align: right; width: 5em; }
.osiris-recap .incomplete { color: var(--error-fg, #ba2121); font-weight: 600; }
.osiris-recap a { font-weight: 600; }
.osiris-note-entry { padding: 10px 0; border-bottom: 1px solid var(--hairline-color); }
.osiris-note-entry:last-child { border-bottom: none; }
.osiris-note-entry p { margin: 4px 0 0; white-space: pre-line; }
.dose-detail { color: var(--body-quiet-color); font-size: 0.85rem; }
.osiris-stats { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 12px; }
.osiris-stats div { font-size: 0.85rem; color: var(--body-quiet-color); }
.osiris-stats strong { display: block; font-size: 1.15rem; color: var(--body-fg); }
.osiris-chart { width: 100%; height: auto; overflow: visible; }
.osiris-empty { color: var(--body-quiet-color); font-style: italic; }
</style>
{% endblock %}
{% block breadcrumbs %}{% endblock %}
{% block content %}
<div class="osiris-card">
<h2 style="margin-top:0">Doses — last 14 days</h2>
<table class="osiris-recap">
<thead>
<tr>
<th>Day</th>
<th class="status">Given</th>
<th class="bpm">Pulse</th>
</tr>
</thead>
<tbody>
{% for row in recap_days %}
<tr>
<td>
<a href="{% url 'tracker:today' %}?date={{ row.date|date:'Y-m-d' }}">{{ row.date|date:"D j M" }}</a>
{% if forloop.first %}<span class="dose-detail">today</span>{% endif %}
</td>
<td class="status">
{% if row.complete %}
{% else %}
<span class="incomplete">{{ row.taken }}/{{ row.expected }}</span>
{% endif %}
</td>
<td class="bpm">{% if row.bpm %}{{ row.bpm }}{% else %}<span class="dose-detail"></span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="dose-detail" style="margin-bottom:0">
✅ means every active dose was ticked off that day; otherwise the count given
out of expected. Tap a day to fill it in.
</p>
</div>
<div class="osiris-card">
<h2 style="margin-top:0">Pulse — last 90 days</h2>
{% if chart %}
<svg class="osiris-chart" viewBox="0 0 {{ chart.width }} {{ chart.height }}" role="img"
aria-label="Pulse from {{ chart.first_date }} to {{ chart.last_date }}">
<line x1="24" y1="24" x2="{{ chart.width|add:'-24' }}" y2="24"
stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
<line x1="24" y1="{{ chart.height|add:'-24' }}" x2="{{ chart.width|add:'-24' }}"
y2="{{ chart.height|add:'-24' }}" stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
<polyline points="{{ chart.polyline }}" fill="none" stroke="#79aec8" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>
{% for point in chart.points %}
<circle cx="{{ point.x }}" cy="{{ point.y }}" r="2" fill="#417690">
<title>{{ point.reading.date }} — {{ point.reading.bpm }} bpm</title>
</circle>
{% endfor %}
<text x="2" y="27" font-size="8" fill="currentColor" opacity="0.6">{{ chart.high }}</text>
<text x="2" y="{{ chart.height|add:'-21' }}" font-size="8" fill="currentColor" opacity="0.6">{{ chart.low }}</text>
</svg>
<div class="osiris-stats">
<div>Average<strong>{{ chart.average }} bpm</strong></div>
<div>Range<strong>{{ chart.low }}{{ chart.high }}</strong></div>
<div>Readings<strong>{{ reading_count }}</strong></div>
<div>
Trend
<strong>
{% if chart.trend > 0 %}&uarr; +{{ chart.trend }}{% elif chart.trend < 0 %}&darr; {{ chart.trend }}{% else %}&rarr; steady{% endif %}
{% if chart.trend %}<span style="font-size:0.75rem">bpm/day</span>{% endif %}
</strong>
</div>
</div>
{% else %}
<p class="osiris-empty">Record the pulse on at least two days to see the graph.</p>
{% endif %}
</div>
<div class="osiris-card">
<h2 style="margin-top:0">Daily notes</h2>
{% for note in daily_notes %}
<div class="osiris-note-entry">
<a href="{% url 'tracker:today' %}?date={{ note.date|date:'Y-m-d' }}" class="dose-detail">
{{ note.date|date:"l j F" }}
</a>
<p>{{ note.body }}</p>
</div>
{% empty %}
<p class="osiris-empty">No notes yet — write one on the Today page.</p>
{% endfor %}
</div>
<p style="text-align:center"><a href="{% url 'tracker:today' %}">&larr; Back to today</a></p>
{% endblock %}
-362
View File
@@ -1,362 +0,0 @@
{% extends "admin/base_site.html" %}
{% block title %}Osiris — {{ day.date|date:"D d M" }}{% endblock %}
{% block extrastyle %}
{{ block.super }}
<style>
/* Mobile-first: big touch targets, single column, no horizontal scrolling. */
#content { padding: 12px; max-width: 640px; margin: 0 auto; }
.osiris-card {
background: var(--body-bg);
border: 1px solid var(--hairline-color);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.osiris-daynav {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; margin-bottom: 16px;
}
.osiris-daynav a, .osiris-daynav strong { font-size: 1rem; }
.osiris-daynav .today-label { text-align: center; flex: 1; }
.osiris-dose {
display: flex; align-items: center; gap: 14px;
padding: 14px 4px;
border-bottom: 1px solid var(--hairline-color);
cursor: pointer;
}
.osiris-dose:last-of-type { border-bottom: none; }
.osiris-dose input[type="checkbox"] {
width: 28px; height: 28px; flex: none; margin: 0;
}
.osiris-dose .dose-name { font-weight: 600; }
.osiris-dose .dose-detail { color: var(--body-quiet-color); font-size: 0.85rem; }
.osiris-dose.is-taken .dose-name { color: var(--object-tools-bg, #417690); }
.osiris-timeofday {
text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.06em;
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);
}
.osiris-save {
width: 100%; padding: 16px; font-size: 1.05rem; font-weight: 600;
border: none; border-radius: 10px; cursor: pointer;
background: var(--button-bg); color: var(--button-fg);
}
.osiris-empty { color: var(--body-quiet-color); font-style: italic; }
</style>
{% endblock %}
{% block breadcrumbs %}{% endblock %}
{% block content %}
<div class="osiris-daynav">
<a href="?date={{ previous_date|date:'Y-m-d' }}">&larr; Prev</a>
<span class="today-label">
<strong>{{ day.date|date:"l j F" }}</strong>
{% if is_today %}<br><small>Today</small>{% endif %}
</span>
{% if is_today %}
<span></span>
{% else %}
<a href="?date={{ next_date|date:'Y-m-d' }}">Next &rarr;</a>
{% endif %}
</div>
<form method="post">
{% csrf_token %}
<input type="hidden" name="date" value="{{ day.date|date:'Y-m-d' }}">
<div class="osiris-card">
<h2 style="margin-top:0">Medication</h2>
{% regroup day.doses by time_of_day_label as dose_groups %}
{% for group in dose_groups %}
<div class="osiris-timeofday">{{ group.grouper }}</div>
{% for dose in group.list %}
<label class="osiris-dose {% if dose.taken %}is-taken{% endif %}">
<input type="checkbox" name="dose-{{ dose.id }}" {% if dose.taken %}checked{% endif %}>
<span>
<span class="dose-name">{{ dose.medication_name }}</span><br>
<span class="dose-detail">{{ dose.amount }}{% if dose.taken is None %} — not recorded{% endif %}</span>
</span>
</label>
{% endfor %}
{% empty %}
<p class="osiris-empty">
No active medication yet.
<a href="{% url 'admin:tracker_medication_add' %}">Add one</a>.
</p>
{% endfor %}
</div>
<div class="osiris-card osiris-pulse">
<h2 style="margin-top:0">Pulse</h2>
<label for="bpm" class="dose-detail">Beats per minute (leave empty to clear)</label>
<input type="number" id="bpm" name="bpm" inputmode="numeric" min="20" max="400"
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" id="reminders" style="margin-top:16px">
<h2 style="margin-top:0">Reminders</h2>
<p class="dose-detail" id="reminder-status">Checking…</p>
<div style="display:flex; gap:8px; flex-wrap:wrap">
<button type="button" id="reminder-toggle" class="osiris-save" style="flex:1; min-width:140px" hidden></button>
<button type="button" id="reminder-test" class="osiris-save" style="flex:1; min-width:140px; background:transparent; color:inherit; border:1px solid var(--border-color)" hidden>
Send test
</button>
</div>
<p class="dose-detail">
A reminder is sent at each dose's reminder time, and only if it isn't already ticked off.
Set those times on the medication in the admin.
</p>
<details class="osiris-debug">
<summary class="dose-detail" style="cursor:pointer">Diagnostics</summary>
<ul id="reminder-checks"></ul>
<ul>
<li class="dose-detail">
{% if doses_with_reminders %}✅{% else %}❌{% endif %}
Doses with a reminder time: {{ doses_with_reminders }} of {{ day.doses|length }}
{% if not doses_with_reminders %}— set one on the medication in the admin, or nothing will ever fire{% endif %}
</li>
<li class="dose-detail">
{% if subscribed_devices %}✅{% else %}❌{% endif %}
Devices subscribed (this account): {{ subscribed_devices }}
</li>
</ul>
</details>
</div>
<p style="text-align:center">
<a href="{% url 'tracker:recap' %}">Recap: doses, pulse &amp; notes &rarr;</a><br>
<a href="{% url 'admin:index' %}">Manage medication &amp; history &rarr;</a>
</p>
<script>
(function () {
const VAPID_PUBLIC_KEY = "{{ vapid_public_key|escapejs }}";
const status = document.getElementById("reminder-status");
const toggle = document.getElementById("reminder-toggle");
const test = document.getElementById("reminder-test");
const checks = document.getElementById("reminder-checks");
const csrf = document.querySelector("[name=csrfmiddlewaretoken]").value;
// Every precondition for Web Push, each one reported by name. Push fails silently
// in a dozen ways; the point here is to say which one, rather than hide the card.
function preconditions() {
return [
{
name: "Server VAPID keys",
ok: Boolean(VAPID_PUBLIC_KEY),
detail: VAPID_PUBLIC_KEY
? VAPID_PUBLIC_KEY.slice(0, 12) + "…"
: "not set — run: manage.py generate_vapid_keys, then set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY in the server environment and restart",
},
{
name: "Secure context (HTTPS)",
ok: window.isSecureContext,
detail: window.isSecureContext
? location.origin
: location.origin + " — push needs https:// (or localhost)",
},
{
name: "Service worker support",
ok: "serviceWorker" in navigator,
detail: "serviceWorker" in navigator ? "supported" : "not supported by this browser",
},
{
name: "Push support",
ok: "PushManager" in window,
detail:
"PushManager" in window
? "supported"
: "not supported — on Android use Chrome; Firefox in a private window will not do",
},
{
name: "Notification permission",
ok: typeof Notification !== "undefined" && Notification.permission !== "denied",
detail:
typeof Notification === "undefined"
? "Notification API missing"
: Notification.permission,
},
];
}
function renderChecks(extra) {
const rows = preconditions().concat(extra || []);
checks.innerHTML = "";
for (const row of rows) {
const li = document.createElement("li");
li.className = "dose-detail";
li.textContent = `${row.ok ? "✅" : "❌"} ${row.name}: ${row.detail}`;
checks.appendChild(li);
}
return rows.every((row) => row.ok);
}
const blocking = preconditions().filter((row) => !row.ok);
if (blocking.length) {
// Notification.permission === "denied" is recoverable, so it is not fatal here;
// anything else means the button would do nothing at all.
const fatal = blocking.filter((row) => row.name !== "Notification permission");
if (fatal.length) {
status.textContent = `Reminders unavailable — ${fatal[0].name}: ${fatal[0].detail}`;
renderChecks();
document.querySelector(".osiris-debug").open = true;
return;
}
}
function post(url, body) {
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRFToken": csrf },
body: JSON.stringify(body || {}),
}).then(async (response) => {
if (!response.ok) {
const detail = await response.json().catch(() => ({}));
throw new Error(detail.detail || `Request failed (${response.status})`);
}
return response.json();
});
}
// The browser wants the key as a Uint8Array, not the base64url we store it as.
function decodeKey(key) {
const padded = (key + "=".repeat((4 - (key.length % 4)) % 4))
.replace(/-/g, "+")
.replace(/_/g, "/");
const raw = atob(padded);
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}
let subscription = null;
let worker = null;
function render() {
renderChecks([
{
name: "Service worker",
ok: Boolean(worker),
detail: worker ? "active at " + worker.scope : "not registered yet — reload the page",
},
{
name: "This device subscribed",
ok: Boolean(subscription),
detail: subscription ? subscription.endpoint.slice(0, 48) + "…" : "no",
},
{
name: "Installed as an app",
ok: window.matchMedia("(display-mode: standalone)").matches,
detail: window.matchMedia("(display-mode: standalone)").matches
? "yes"
: "no — works in the browser too, but Android delivers reliably once installed",
},
]);
if (Notification.permission === "denied") {
status.textContent =
"Notifications are blocked for this site. Re-allow them in Chrome → site settings → Notifications.";
toggle.hidden = true;
test.hidden = true;
return;
}
toggle.hidden = false;
toggle.disabled = false;
toggle.textContent = subscription ? "Turn reminders off" : "Turn reminders on";
test.hidden = !subscription;
status.textContent = subscription
? "Reminders are on for this device."
: "Reminders are off for this device.";
}
navigator.serviceWorker.ready
.then((registration) => {
worker = registration;
return registration.pushManager.getSubscription();
})
.then((existing) => {
subscription = existing;
render();
})
.catch((error) => {
status.textContent = "Service worker failed: " + error.message;
renderChecks();
document.querySelector(".osiris-debug").open = true;
});
toggle.addEventListener("click", async () => {
toggle.disabled = true;
try {
if (subscription) {
await post("/api/push/unsubscribe", { endpoint: subscription.endpoint });
await subscription.unsubscribe();
subscription = null;
} else {
const permission = await Notification.requestPermission();
if (permission !== "granted") return render();
const registration = await navigator.serviceWorker.ready;
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: decodeKey(VAPID_PUBLIC_KEY),
});
await post("/api/push/subscribe", subscription.toJSON());
}
render();
} catch (error) {
status.textContent = error.message;
toggle.disabled = false;
}
});
test.addEventListener("click", async () => {
test.disabled = true;
status.textContent = "Sending…";
try {
const result = await post("/api/push/test");
status.textContent = result.detail;
} catch (error) {
status.textContent = error.message;
}
test.disabled = false;
});
})();
</script>
{% endblock %}