feat: pwa

This commit is contained in:
Loïc Gremaud 2026-07-14 20:23:48 +02:00
parent 4040b4fdd9
commit 9a795dc3c9
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
10 changed files with 178 additions and 0 deletions

View File

@ -34,6 +34,36 @@ DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.1.x:8000 uv run python manage.py runs
Sessions last a year, so your phone stays logged in. Sessions last a year, so your phone stays logged in.
## Installing it on Android (PWA)
The Today page is a progressive web app: Chrome will offer *Add to home screen*
(menu → Install app), and it then launches full-screen with its own icon, no
browser chrome, and a year-long session. No APK, no Play Store, no Flutter.
**It must be served over HTTPS** — service workers and installability are
disabled on plain `http://` (except `localhost`). Over LAN HTTP you still get a
usable mobile site, just a bookmark rather than an installed app. The easy ways
to get a real certificate:
```bash
# Tailscale (private to your devices, no public exposure):
tailscale serve --bg 8000
# or Cloudflare Tunnel (public URL):
cloudflared tunnel --url http://localhost:8000
```
Whichever you use, add the hostname to `DJANGO_ALLOWED_HOSTS` and
`DJANGO_CSRF_TRUSTED_ORIGINS`.
Offline behaviour is deliberately conservative: static files are cached, and the
last-seen page is shown if you open the app with no signal, but `/api/` calls and
every write go straight to the network — a cached or replayed dose would be a
lie about whether the cat was medicated.
If you ever do need a real APK (Play Store, or an installable file), the manifest
here is all [PWABuilder](https://www.pwabuilder.com) needs to wrap it in a
Trusted Web Activity — no Android SDK on your side.
## Data model ## Data model
- `Medication` — the drug, e.g. Vetmedin. Deactivate rather than delete when a - `Medication` — the drug, e.g. Vetmedin. Deactivate rather than delete when a

BIN
assets/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 B

BIN
assets/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,31 @@
{
"name": "Osiris",
"short_name": "Osiris",
"description": "Medication and pulse tracking for Osiris the cat.",
"start_url": "/admin/today/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#202a35",
"theme_color": "#202a35",
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@ -129,6 +129,8 @@ USE_TZ = True
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static" STATIC_ROOT = BASE_DIR / "static"
# PWA icons and manifest live here; STATIC_ROOT is only the collectstatic output.
STATICFILES_DIRS = [BASE_DIR / "assets"]
# Default primary key field type # Default primary key field type
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

View File

@ -1,6 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import include, path from django.urls import include, path
from django.views.generic import TemplateView
from osiris.api import api from osiris.api import api
@ -10,6 +11,15 @@ admin.site.index_title = "Medication & pulse tracking"
urlpatterns = [ urlpatterns = [
path("api/", api.urls), path("api/", api.urls),
# Must be served from the root: a worker under /static/ could only control
# /static/, not the /admin/ pages it exists to cache.
path(
"sw.js",
TemplateView.as_view(
template_name="sw.js", content_type="application/javascript"
),
name="service-worker",
),
# Sits under /admin/ so it inherits the admin's styling and login, but must # Sits under /admin/ so it inherits the admin's styling and login, but must
# come before the admin's own catch-all patterns. # come before the admin's own catch-all patterns.
path("admin/", include("tracker.urls")), path("admin/", include("tracker.urls")),

View File

@ -1,7 +1,22 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% load static %}
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Osiris') }}{% endblock %} {% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Osiris') }}{% endblock %}
{% block extrahead %}
{{ block.super }}
<link rel="manifest" href="{% static 'manifest.webmanifest' %}">
<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' %}">
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => navigator.serviceWorker.register("/sw.js"));
}
</script>
{% endblock %}
{% block branding %} {% block branding %}
<div id="site-name"><a href="{% url 'admin:index' %}">{{ site_header|default:_('Osiris') }}</a></div> <div id="site-name"><a href="{% url 'admin:index' %}">{{ site_header|default:_('Osiris') }}</a></div>
{% if user.is_anonymous %} {% if user.is_anonymous %}

68
templates/sw.js Normal file
View File

@ -0,0 +1,68 @@
// Served from / (not /static/) so its scope covers /admin/today/.
const CACHE = "osiris-v1";
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("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" } }
)
);
})
);
});

View File

@ -229,3 +229,25 @@ class TodayPageTests(TrackerTestCase):
response = self.client.get(reverse("tracker:today")) response = self.client.get(reverse("tracker:today"))
self.assertContains(response, "<polyline") self.assertContains(response, "<polyline")
self.assertContains(response, "180.0 bpm") # average self.assertContains(response, "180.0 bpm") # average
class PwaTests(TrackerTestCase):
def test_the_service_worker_is_served_from_the_root(self):
# Scope: under /static/ it could not control the /admin/ pages it caches.
response = self.client.get("/sw.js")
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "application/javascript")
def test_the_page_links_the_manifest_and_registers_the_worker(self):
self.client.force_login(self.user)
response = self.client.get(reverse("tracker:today"))
self.assertContains(response, 'rel="manifest"')
self.assertContains(response, "serviceWorker.register")
def test_the_login_page_is_installable_too(self):
# The app opens on the login screen when the session lapses.
response = self.client.get("/admin/login/")
self.assertContains(response, 'rel="manifest"')