270 lines
10 KiB
HTML
270 lines
10 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-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' }}">← 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>
|
||
|
||
<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 %}↑ +{{ chart.trend }}{% elif chart.trend < 0 %}↓ {{ chart.trend }}{% else %}→ 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" hidden>
|
||
<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"></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" style="margin-bottom:0">
|
||
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>
|
||
</div>
|
||
|
||
<p style="text-align:center"><a href="{% url 'admin:index' %}">Manage medication & history →</a></p>
|
||
|
||
<script>
|
||
(function () {
|
||
const VAPID_PUBLIC_KEY = "{{ vapid_public_key|escapejs }}";
|
||
const card = document.getElementById("reminders");
|
||
const status = document.getElementById("reminder-status");
|
||
const toggle = document.getElementById("reminder-toggle");
|
||
const test = document.getElementById("reminder-test");
|
||
|
||
// No keys configured, or a browser without push: leave the card hidden entirely.
|
||
if (!VAPID_PUBLIC_KEY || !("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||
return;
|
||
}
|
||
card.hidden = false;
|
||
|
||
const csrf = document.querySelector("[name=csrfmiddlewaretoken]").value;
|
||
|
||
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;
|
||
|
||
function render() {
|
||
const blocked = Notification.permission === "denied";
|
||
if (blocked) {
|
||
status.textContent =
|
||
"Notifications are blocked for this site. Re-allow them in Chrome's site settings.";
|
||
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) => registration.pushManager.getSubscription())
|
||
.then((existing) => {
|
||
subscription = existing;
|
||
render();
|
||
});
|
||
|
||
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 %}
|