363 lines
13 KiB
HTML
363 lines
13 KiB
HTML
{% 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' }}">← 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 →</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 & notes →</a><br>
|
|
<a href="{% url 'admin:index' %}">Manage medication & history →</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 %}
|