Make the web app installable as a PWA (Tier 1: app shell + offline fallback)

Adds a manifest, service worker, and branded icons so the site can be
installed to a home screen/desktop, plus an offline fallback page so a
dropped connection shows something friendlier than the browser's default
error. Icons are rasterized from the existing favicon.svg mark via
rsvg-convert. The manifest and service worker are served through small
Django views (not raw static files) so their asset URLs pick up
WhiteNoise's content-hashed filenames in prod/selfhosted, and the service
worker is served from the site root so its scope covers the whole app.

Does not include true offline task data or Web Push notifications --
those are tracked separately as larger follow-up projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Keith Smith
2026-09-04 22:03:12 -06:00
co-authored by Claude Sonnet 5
parent a23600a486
commit 30c61351de
11 changed files with 136 additions and 1 deletions
+7 -1
View File
@@ -6,18 +6,24 @@ from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from django.views.generic import TemplateView
from users.urls import api_urlpatterns as users_api_urls, web_urlpatterns as users_web_urls
from tasks.urls import api_urlpatterns as tasks_api_urls, web_urlpatterns as tasks_web_urls
from sync.urls import api_urlpatterns as sync_api_urls
from notifications.urls import api_urlpatterns as notifications_api_urls
from tasks.views_debug import debug_user_agent
from .views import api_root
from .views import api_root, manifest_webmanifest, service_worker
urlpatterns = [
# Admin
path('admin/', admin.site.urls),
# PWA
path('manifest.webmanifest', manifest_webmanifest, name='manifest'),
path('sw.js', service_worker, name='service-worker'),
path('offline/', TemplateView.as_view(template_name='offline.html'), name='offline'),
# Debug endpoint (remove in production)
path('debug/user-agent/', debug_user_agent, name='debug-user-agent'),
+17
View File
@@ -3,6 +3,23 @@ API root views for KeepItGoing.
"""
from django.http import JsonResponse
from django.shortcuts import render
def manifest_webmanifest(request):
"""Serve the PWA web app manifest, rendered so icon URLs pick up static hashing."""
return render(request, 'manifest.webmanifest', content_type='application/manifest+json')
def service_worker(request):
"""
Serve the service worker script from the site root so its default scope
covers the whole app (a service worker can only control paths at or
below where it's served from).
"""
response = render(request, 'sw.js', content_type='application/javascript')
response['Cache-Control'] = 'no-cache'
return response
def api_root(request):
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

+11
View File
@@ -7,8 +7,19 @@ document.addEventListener('DOMContentLoaded', function() {
initTheme();
initTaskSelection();
initTimerDisplays();
registerServiceWorker();
});
/* ============================================
Service Worker
============================================ */
function registerServiceWorker() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
}
/* ============================================
Theme Toggle
============================================ */
+7
View File
@@ -9,6 +9,13 @@
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="{% static 'favicons/favicon.svg' %}">
<link rel="alternate icon" href="{% static 'favicons/favicon.svg' %}" type="image/svg+xml">
<link rel="apple-touch-icon" href="{% static 'favicons/apple-touch-icon-180.png' %}">
<!-- PWA -->
<link rel="manifest" href="{% url 'manifest' %}">
<meta name="theme-color" content="#3B82F6">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="stylesheet" href="{% static 'css/app.css' %}?v=6">
{% block extra_css %}{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% load static %}{
"name": "KeepItGoing",
"short_name": "KeepItGoing",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3B82F6",
"icons": [
{
"src": "{% static 'favicons/icon-192.png' %}",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "{% static 'favicons/icon-512.png' %}",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "{% static 'favicons/icon-512-maskable.png' %}",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+19
View File
@@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% block title %}You're Offline - KeepItGoing{% endblock %}
{% block content %}
<div class="empty-state">
<p>You're offline</p>
<p class="text-muted">This page isn't available without a connection. Reconnect and try again.</p>
<button class="btn btn-primary" onclick="window.location.reload()">Retry</button>
</div>
{% endblock %}
{% block auth_content %}
<div class="empty-state">
<p>You're offline</p>
<p class="text-muted">This page isn't available without a connection. Reconnect and try again.</p>
<button class="btn btn-primary" onclick="window.location.reload()">Retry</button>
</div>
{% endblock %}
+49
View File
@@ -0,0 +1,49 @@
{% load static %}const CACHE_NAME = 'keepitgoing-shell-v1';
const OFFLINE_URL = '{% url "offline" %}';
const PRECACHE_URLS = [
"{% static 'css/app.css' %}",
"{% static 'js/app.js' %}",
"{% static 'favicons/favicon.svg' %}",
"{% static 'favicons/icon-192.png' %}",
"{% static 'favicons/icon-512.png' %}",
"{% static 'favicons/icon-512-maskable.png' %}",
"{% static 'favicons/apple-touch-icon-180.png' %}",
OFFLINE_URL,
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const request = event.request;
// Navigations: try the network first, fall back to the offline page.
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match(OFFLINE_URL))
);
return;
}
// Precached shell assets: serve from cache first.
const path = new URL(request.url).pathname;
if (PRECACHE_URLS.includes(path)) {
event.respondWith(
caches.match(request).then((cached) => cached || fetch(request))
);
}
});