osiris/templates/tracker/today.html

375 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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-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-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-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>
<button type="submit" class="osiris-save">Save {{ day.date|date:"D j M" }}</button>
</form>
<div class="osiris-card" style="margin-top:16px">
<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" id="reminders">
<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 '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 %}