feat: PWA update strategy with hashed assets and cache control

Move frontend sources to frontend/, build content-hashed assets at deploy,
network-first SW for shell/JS, immutable cache for /assets/, version.json
update banner, and Cache-Control middleware. Docs in docs/PWA-STRATEGY.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank Schwenk
2026-07-08 08:51:12 +02:00
parent bff14167c9
commit 7cec3030c1
29 changed files with 779 additions and 61 deletions
+2
View File
@@ -20,6 +20,7 @@ from server.push import save_subscription, send_slot_reminder
from server.scheduler import schedule_snooze, start_scheduler
from server.settings import settings
from server.slots import build_today
from server.static_cache import CacheControlMiddleware
from server.stats import build_history, check_milestones, compute_stats, MILESTONE_DEFS
NO_STORE = {"Cache-Control": "no-store"}
@@ -63,6 +64,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="TakeYourMeds", version=settings.app_version, lifespan=lifespan)
app.add_middleware(CacheControlMiddleware)
@app.post("/api/auth/pin")
+1 -1
View File
@@ -22,7 +22,7 @@ class Settings(BaseSettings):
messages_path: str = "messages.yaml"
public_dir: str = "public"
app_version: str = "1.0.0"
app_version: str = "1.1.0"
settings = Settings() # type: ignore[call-arg]
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
from starlette.types import ASGIApp, Receive, Scope, Send
NO_CACHE = b"no-cache, must-revalidate"
NO_STORE = b"no-store"
IMMUTABLE = b"public, max-age=31536000, immutable"
def _cache_control_for_path(path: str) -> bytes | None:
if path in ("/sw.js", "/index.html", "/manifest.json"):
return NO_CACHE
if path == "/version.json":
return NO_STORE
if path.startswith("/assets/"):
return IMMUTABLE
if path.endswith((".js", ".css")) and not path.startswith("/assets/"):
return NO_CACHE
return None
class CacheControlMiddleware:
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
cache_control = _cache_control_for_path(path)
async def send_wrapper(message: dict) -> None:
if message["type"] == "http.response.start" and cache_control is not None:
headers = list(message.get("headers", []))
headers = [(k, v) for k, v in headers if k.lower() != b"cache-control"]
headers.append((b"cache-control", cache_control))
message = {**message, "headers": headers}
await send(message)
await self.app(scope, receive, send_wrapper)