Compare commits
3 Commits
24ac7f2f48
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cec3030c1 | |||
| bff14167c9 | |||
| 1cada42370 |
@@ -1,5 +1,7 @@
|
|||||||
.env
|
.env
|
||||||
data/
|
data/
|
||||||
|
public/assets/*
|
||||||
|
!public/assets/.gitkeep
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
@@ -7,3 +9,5 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
public/assets/*
|
||||||
|
!public/assets/.gitkeep
|
||||||
|
|||||||
+4
-2
@@ -9,9 +9,11 @@ COPY pyproject.toml /app/pyproject.toml
|
|||||||
COPY server /app/server
|
COPY server /app/server
|
||||||
RUN pip install --no-cache-dir -U pip && pip install --no-cache-dir .
|
RUN pip install --no-cache-dir -U pip && pip install --no-cache-dir .
|
||||||
|
|
||||||
COPY public /app/public
|
COPY frontend /app/frontend
|
||||||
COPY meds.yaml messages.yaml /app/
|
|
||||||
COPY tools /app/tools
|
COPY tools /app/tools
|
||||||
|
RUN python tools/build_frontend.py
|
||||||
|
|
||||||
|
COPY meds.yaml messages.yaml /app/
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
@@ -59,11 +59,14 @@ pip install -e .
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# APP_PIN und JWT_SECRET anpassen
|
# APP_PIN und JWT_SECRET anpassen
|
||||||
|
|
||||||
# 3. VAPID keys (für Push)
|
# 3. Frontend bauen (gehashte Assets)
|
||||||
|
python tools/build_frontend.py
|
||||||
|
|
||||||
|
# 4. VAPID keys (für Push)
|
||||||
python tools/gen_vapid.py
|
python tools/gen_vapid.py
|
||||||
# Output in .env eintragen
|
# Output in .env eintragen
|
||||||
|
|
||||||
# 4. Starten
|
# 5. Starten
|
||||||
uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
|
uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -190,14 +193,15 @@ Notification-Actions: **Genommen ✓**, **+15 Min**, **+30 Min**
|
|||||||
|
|
||||||
```
|
```
|
||||||
takeyourmeds/
|
takeyourmeds/
|
||||||
|
├── frontend/ # PWA-Quellen (JS, CSS, Templates)
|
||||||
├── compose.yml # Docker + Traefik
|
├── compose.yml # Docker + Traefik
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── meds.yaml # Medikamenten-Zeitplan
|
├── meds.yaml # Medikamenten-Zeitplan
|
||||||
├── messages.yaml # Humor-Pool
|
├── messages.yaml # Humor-Pool
|
||||||
├── icon.png / icon.svg # App-Icons (Quelle)
|
├── icon.png / icon.svg # App-Icons (Quelle)
|
||||||
├── server/ # FastAPI Backend
|
├── server/ # FastAPI Backend
|
||||||
├── public/ # PWA Frontend
|
├── public/ # Build-Output (index.html, sw.js, assets/)
|
||||||
├── tools/ # schema.sql, gen_vapid.py
|
├── tools/ # schema.sql, gen_vapid.py, build_frontend.py
|
||||||
└── data/ # SQLite (gitignored)
|
└── data/ # SQLite (gitignored)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -207,10 +211,13 @@ takeyourmeds/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
|
python tools/build_frontend.py # nach Änderungen in frontend/
|
||||||
uvicorn server.app:app --reload --port 8000
|
uvicorn server.app:app --reload --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
Service Worker cached aggressiv — für SW-Änderungen: DevTools → Application → Clear storage, oder Inkognito.
|
Quellen liegen in `frontend/`; Build-Output in `public/` (gehashte JS/CSS unter `public/assets/`). PWA-Update-Strategie: `docs/PWA-STRATEGY.md`.
|
||||||
|
|
||||||
|
Service Worker: bei Deploy neuer `CACHE`-Name automatisch via Build. Update-Banner erscheint bei veralteter Client-Version.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# PWA Update-Strategie
|
||||||
|
|
||||||
|
Wiederverwendbare Strategie für Fränkys PWAs — damit Nutzer:innen **ohne Cache leeren** immer die aktuelle Version sehen.
|
||||||
|
|
||||||
|
> Gitea-Wiki: manuell anlegen unter [PWA-Update-Strategie](https://gitea.schwenk.online/froxxxy/takeyourmeds/wiki/_pages) (MCP-Token hatte keine Wiki-Schreibrechte).
|
||||||
|
|
||||||
|
## Warum?
|
||||||
|
|
||||||
|
- Viele Nutzer:innen wissen nicht, was „Cache leeren" bedeutet (besonders auf dem Handy / als installierte PWA).
|
||||||
|
- Service Worker können alte Dateien **monatelang** vorhalten — ein Deploy reicht nicht.
|
||||||
|
- Ziel: Updates **sichtbar und kontrolliert** (Banner + Reload), nicht stilles Veralten.
|
||||||
|
|
||||||
|
## Drei Ebenen (Tiers)
|
||||||
|
|
||||||
|
### Tier 1 — Service Worker & Client
|
||||||
|
|
||||||
|
| Element | Strategie |
|
||||||
|
|---------|-----------|
|
||||||
|
| `sw.js` | Nicht vom SW intercepten; Server: `Cache-Control: no-cache` |
|
||||||
|
| `index.html` | Network-first |
|
||||||
|
| Unversioniertes JS/CSS | Network-first |
|
||||||
|
| Cache-Name | Pro Build: `tym-v{version}-{buildHash}` |
|
||||||
|
| `version.json` | Network-only im SW; Client-Check beim Start/Focus |
|
||||||
|
| Update-Banner | `buildHash` ≠ `meta app-build` → „Aktualisieren" |
|
||||||
|
|
||||||
|
### Tier 2 — Server & Lifecycle
|
||||||
|
|
||||||
|
| Element | Maßnahme |
|
||||||
|
|---------|----------|
|
||||||
|
| HTTP-Header | `server/static_cache.py` Middleware |
|
||||||
|
| Focus | `registration.update()` + `version.json` Check |
|
||||||
|
| `controllerchange` | Einmalig `location.reload()` |
|
||||||
|
|
||||||
|
### Tier 3 — Build
|
||||||
|
|
||||||
|
| Element | Maßnahme |
|
||||||
|
|---------|----------|
|
||||||
|
| Quellen | `frontend/` |
|
||||||
|
| Output | `public/assets/*.{hash}.js/css` |
|
||||||
|
| Build | `python tools/build_frontend.py` (läuft im Dockerfile) |
|
||||||
|
| Gehashte Assets | `Cache-Control: immutable` |
|
||||||
|
|
||||||
|
## Deploy-Checkliste
|
||||||
|
|
||||||
|
- [ ] Version in `pyproject.toml` bumpen (optional)
|
||||||
|
- [ ] `python tools/build_frontend.py` (Docker macht das automatisch)
|
||||||
|
- [ ] `docker compose up -d --build`
|
||||||
|
- [ ] Inkognito: `sw.js` neue CACHE-Konstante, `version.json` neuer `buildHash`
|
||||||
|
- [ ] Alte PWA-Installation: Update-Banner nach App-Öffnung
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
- Cache-first für unversioniertes `app.js`
|
||||||
|
- `sw.js` im Precache
|
||||||
|
- Stub-Endpoints für fehlende APIs
|
||||||
|
- „Cache leeren" als einziger Fix in der Doku
|
||||||
@@ -3,6 +3,7 @@ import { getToken, setToken, clearToken, isLoggedIn } from "./lib/auth.js";
|
|||||||
import { setStreakBadge } from "./lib/badge.js";
|
import { setStreakBadge } from "./lib/badge.js";
|
||||||
import { enqueue, flushQueue } from "./lib/offlineQueue.js";
|
import { enqueue, flushQueue } from "./lib/offlineQueue.js";
|
||||||
import { registerPwa, wirePwaInstall, subscribePush } from "./lib/pwa.js";
|
import { registerPwa, wirePwaInstall, subscribePush } from "./lib/pwa.js";
|
||||||
|
import { wireAppUpdates } from "./lib/updates.js";
|
||||||
import { renderSlots, renderHeatmap, updateStats, showToast, showConfetti, showEasterEgg } from "./lib/ui.js";
|
import { renderSlots, renderHeatmap, updateStats, showToast, showConfetti, showEasterEgg } from "./lib/ui.js";
|
||||||
|
|
||||||
const STATUS = { upcoming: "Noch nicht", pending: "Jetzt!", overdue: "Überfällig", snoozed: "Snoozed", taken: "Genommen", missed: "Verpasst" };
|
const STATUS = { upcoming: "Noch nicht", pending: "Jetzt!", overdue: "Überfällig", snoozed: "Snoozed", taken: "Genommen", missed: "Verpasst" };
|
||||||
@@ -179,6 +180,7 @@ navigator.serviceWorker?.addEventListener("message", (event) => {
|
|||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
registerPwa();
|
registerPwa();
|
||||||
|
wireAppUpdates();
|
||||||
if (isLoggedIn()) {
|
if (isLoggedIn()) {
|
||||||
try { await initApp(); return; } catch { clearToken(); }
|
try { await initApp(); return; } catch { clearToken(); }
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
@@ -0,0 +1,104 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#F12F12">
|
||||||
|
<meta name="description" content="Medikamenten-Erinnerung mit Sarkasmus">
|
||||||
|
<meta name="app-build" content="{{BUILD_HASH}}">
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<link rel="icon" href="/icon.png" type="image/png">
|
||||||
|
<link rel="apple-touch-icon" href="/icon-192x192.png">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{{STYLES_HREF}}">
|
||||||
|
<title>TakeYourMeds</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Login -->
|
||||||
|
<section id="loginScreen" class="screen">
|
||||||
|
<div class="loginHero">
|
||||||
|
<img src="/icon-192x192.png" alt="TakeYourMeds" class="loginLogo" width="120" height="120">
|
||||||
|
<h1>TakeYourMeds</h1>
|
||||||
|
<p class="tagline">Dein Gehirn. Deine Medis. Unser Sarkasmus.</p>
|
||||||
|
</div>
|
||||||
|
<form id="pinForm" class="pinForm">
|
||||||
|
<input type="number" id="pinInput" class="pinInput" inputmode="numeric" min="0" max="999999" step="1" placeholder="PIN" autocomplete="off" required>
|
||||||
|
<button type="submit" class="btn btn-primary pinSubmit">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
<p class="loginError" id="loginError" hidden></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<section id="mainScreen" class="screen" hidden>
|
||||||
|
<header class="topBar">
|
||||||
|
<img src="/icon.png" alt="" class="topIcon" width="36" height="36">
|
||||||
|
<h1>Medis</h1>
|
||||||
|
<div class="streakBadge" id="streakBadge" title="Streak">🔥 0</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="tabNav">
|
||||||
|
<button type="button" class="tab active" data-tab="dashboard">Heute</button>
|
||||||
|
<button type="button" class="tab" data-tab="history">Verlauf</button>
|
||||||
|
<button type="button" class="tab" data-tab="settings">⚙</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="tabContent">
|
||||||
|
<div id="tab-dashboard" class="tabPanel active">
|
||||||
|
<div class="motivationCard" id="motivationCard">
|
||||||
|
<div class="motivationLabel">ADHD live, laugh, toaster bath motivational</div>
|
||||||
|
<p id="motivationText">…</p>
|
||||||
|
</div>
|
||||||
|
<div id="slotCards" class="slotCards"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-history" class="tabPanel" hidden>
|
||||||
|
<div class="statsRow">
|
||||||
|
<div class="statBox">
|
||||||
|
<div class="statNum" id="statStreak">0</div>
|
||||||
|
<div class="statLabel">Streak</div>
|
||||||
|
</div>
|
||||||
|
<div class="statBox">
|
||||||
|
<div class="statNum" id="statCompliance">0%</div>
|
||||||
|
<div class="statLabel" id="statComplianceLabel">90 Tage</div>
|
||||||
|
</div>
|
||||||
|
<div class="statBox">
|
||||||
|
<div class="statNum" id="statTaken">0</div>
|
||||||
|
<div class="statLabel">Genommen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 class="sectionTitle">90-Tage Heatmap</h2>
|
||||||
|
<div id="heatmap" class="heatmap"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-settings" class="tabPanel" hidden>
|
||||||
|
<div class="settingsGroup">
|
||||||
|
<h2>Benachrichtigungen</h2>
|
||||||
|
<p class="settingsHint">Push-Erinnerungen um 8:00 und 12:00 — dezent, kein Alarm.</p>
|
||||||
|
<button type="button" class="btn btn-accent" id="enablePush">Push aktivieren</button>
|
||||||
|
<p id="pushStatus" class="settingsStatus"></p>
|
||||||
|
</div>
|
||||||
|
<div class="settingsGroup" id="pwaInstallSection" hidden>
|
||||||
|
<h2>App installieren</h2>
|
||||||
|
<button type="button" class="btn btn-secondary" id="installPwa">Auf Homescreen</button>
|
||||||
|
</div>
|
||||||
|
<div class="settingsGroup">
|
||||||
|
<button type="button" class="btn btn-danger" id="logoutBtn">Abmelden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast" class="toast" hidden></div>
|
||||||
|
<div id="updateBanner" class="updateBanner" hidden>
|
||||||
|
<span>Neue Version verfügbar</span>
|
||||||
|
<button type="button" class="btn btn-primary" id="updateBannerBtn">Aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
<div id="confetti" class="confetti" hidden></div>
|
||||||
|
<div id="easterEgg" class="easterEgg" hidden></div>
|
||||||
|
|
||||||
|
<script type="module" src="{{APP_SCRIPT_SRC}}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -36,12 +36,13 @@ export function wirePwaInstall({ sectionId = "pwaInstallSection", buttonId = "in
|
|||||||
export async function registerPwa() {
|
export async function registerPwa() {
|
||||||
if (!("serviceWorker" in navigator)) return;
|
if (!("serviceWorker" in navigator)) return;
|
||||||
try {
|
try {
|
||||||
await navigator.serviceWorker.register("/sw.js");
|
const reg = await navigator.serviceWorker.register("/sw.js");
|
||||||
if ("sync" in ServiceWorkerRegistration.prototype) {
|
if ("sync" in ServiceWorkerRegistration.prototype) {
|
||||||
navigator.serviceWorker.ready.then((reg) => {
|
navigator.serviceWorker.ready.then((readyReg) => {
|
||||||
reg.sync?.register("sync-logs").catch(() => {});
|
readyReg.sync?.register("sync-logs").catch(() => {});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return reg;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("SW registration failed:", e);
|
console.error("SW registration failed:", e);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
let bannerVisible = false;
|
||||||
|
|
||||||
|
function showUpdateBanner() {
|
||||||
|
if (bannerVisible) return;
|
||||||
|
const banner = document.getElementById("updateBanner");
|
||||||
|
if (!banner) return;
|
||||||
|
banner.hidden = false;
|
||||||
|
bannerVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyUpdate() {
|
||||||
|
const reg = await navigator.serviceWorker?.getRegistration();
|
||||||
|
if (reg?.waiting) {
|
||||||
|
reg.waiting.postMessage({ type: "SKIP_WAITING" });
|
||||||
|
} else {
|
||||||
|
await reg?.update();
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wireAppUpdates() {
|
||||||
|
if (!("serviceWorker" in navigator)) return;
|
||||||
|
|
||||||
|
let refreshing = false;
|
||||||
|
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||||
|
if (refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
navigator.serviceWorker.addEventListener("message", (event) => {
|
||||||
|
if (event.data?.type === "UPDATE_AVAILABLE") showUpdateBanner();
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = document.getElementById("updateBannerBtn");
|
||||||
|
button?.addEventListener("click", () => applyUpdate());
|
||||||
|
|
||||||
|
const checkVersion = async () => {
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.getRegistration();
|
||||||
|
await reg?.update();
|
||||||
|
|
||||||
|
const built = document.querySelector('meta[name="app-build"]')?.content;
|
||||||
|
if (!built) return;
|
||||||
|
|
||||||
|
const res = await fetch("/version.json", { cache: "no-store" });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.buildHash && data.buildHash !== built) {
|
||||||
|
showUpdateBanner();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore — offline or transient */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("focus", checkVersion);
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.visibilityState === "visible") checkVersion();
|
||||||
|
});
|
||||||
|
checkVersion();
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "TakeYourMeds",
|
||||||
|
"short_name": "Medis",
|
||||||
|
"description": "Medikamenten-Erinnerung mit Sarkasmus und Dopamin",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#1a1a2e",
|
||||||
|
"theme_color": "#F12F12",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Genommen (Morgens)",
|
||||||
|
"short_name": "Morgens ✓",
|
||||||
|
"url": "/?action=take&slot=morning",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192x192.png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Genommen (Mittags)",
|
||||||
|
"short_name": "Mittags ✓",
|
||||||
|
"url": "/?action=take&slot=noon",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192x192.png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -244,3 +244,15 @@ body {
|
|||||||
}
|
}
|
||||||
.easterEggInner h2 { font-size: 1.5rem; margin-bottom: .75rem; }
|
.easterEggInner h2 { font-size: 1.5rem; margin-bottom: .75rem; }
|
||||||
.easterEggInner p { margin-bottom: 1.25rem; line-height: 1.5; }
|
.easterEggInner p { margin-bottom: 1.25rem; line-height: 1.5; }
|
||||||
|
|
||||||
|
/* PWA update banner */
|
||||||
|
.updateBanner {
|
||||||
|
position: fixed; left: 0; right: 0; bottom: 0; z-index: 1500;
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
|
||||||
|
padding: .85rem 1rem calc(.85rem + env(safe-area-inset-bottom));
|
||||||
|
background: linear-gradient(135deg, var(--primary), #c41f1f);
|
||||||
|
color: #fff; box-shadow: 0 -4px 20px rgba(0,0,0,.35);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.updateBanner[hidden] { display: none !important; }
|
||||||
|
.updateBanner .btn { flex-shrink: 0; padding: .5rem 1rem; font-size: .9rem; }
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
const CACHE = "{{CACHE_VERSION}}";
|
||||||
|
const ASSETS = {{ASSETS_JSON}};
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE).then((c) => c.addAll(ASSETS)).then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((names) =>
|
||||||
|
Promise.all(names.filter((n) => n !== CACHE).map((n) => caches.delete(n)))
|
||||||
|
).then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("message", (event) => {
|
||||||
|
if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
function isHashedAsset(pathname) {
|
||||||
|
return pathname.startsWith("/assets/");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkOnly(request) {
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkFirst(request, fallbackPath) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) return response;
|
||||||
|
} catch {
|
||||||
|
/* offline */
|
||||||
|
}
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
if (fallbackPath) {
|
||||||
|
const fallback = await caches.match(fallbackPath);
|
||||||
|
if (fallback) return fallback;
|
||||||
|
}
|
||||||
|
return new Response("Offline", { status: 503, statusText: "Offline" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheFirst(request) {
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) {
|
||||||
|
const cache = await caches.open(CACHE);
|
||||||
|
await cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
if (request.method !== "GET") return;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// sw.js: never intercept — browser must revalidate with server
|
||||||
|
if (url.pathname === "/sw.js") return;
|
||||||
|
|
||||||
|
if (url.pathname.startsWith("/api/")) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request).catch(() => new Response(JSON.stringify({ offline: true }), {
|
||||||
|
status: 503,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/version.json") {
|
||||||
|
event.respondWith(networkOnly(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.mode === "navigate") {
|
||||||
|
event.respondWith(networkFirst(request, "/index.html"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHashedAsset(url.pathname)) {
|
||||||
|
event.respondWith(cacheFirst(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/index.html" || url.pathname === "/manifest.json") {
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icons and other static root files
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
if (!event.data) return;
|
||||||
|
let data = {};
|
||||||
|
try { data = event.data.json(); } catch { data = { body: event.data.text() }; }
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
body: data.body || "Med-Time!",
|
||||||
|
icon: "/icon-192x192.png",
|
||||||
|
badge: "/icon-72x72.png",
|
||||||
|
silent: data.silent !== false,
|
||||||
|
tag: data.tag || "med-reminder",
|
||||||
|
renotify: true,
|
||||||
|
data: { slot_id: data.slot_id },
|
||||||
|
actions: [
|
||||||
|
{ action: "take", title: "Genommen ✓" },
|
||||||
|
{ action: "snooze15", title: "+15 Min" },
|
||||||
|
{ action: "snooze30", title: "+30 Min" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title || "TakeYourMeds", options)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const slotId = event.notification.data?.slot_id;
|
||||||
|
const action = event.action;
|
||||||
|
|
||||||
|
if (action === "take" && slotId) {
|
||||||
|
event.waitUntil(self.clients.openWindow(`/?action=take&slot=${slotId}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((action === "snooze15" || action === "snooze30") && slotId) {
|
||||||
|
const minutes = action === "snooze15" ? 15 : 30;
|
||||||
|
event.waitUntil(self.clients.openWindow(`/?action=snooze&slot=${slotId}&minutes=${minutes}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(self.clients.openWindow("/"));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("sync", (event) => {
|
||||||
|
if (event.tag === "sync-logs") {
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll().then((list) => {
|
||||||
|
list.forEach((c) => c.postMessage({ type: "SYNC_QUEUE" }));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
+7
-2
@@ -5,12 +5,13 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#F12F12">
|
<meta name="theme-color" content="#F12F12">
|
||||||
<meta name="description" content="Medikamenten-Erinnerung mit Sarkasmus">
|
<meta name="description" content="Medikamenten-Erinnerung mit Sarkasmus">
|
||||||
|
<meta name="app-build" content="914619f3">
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<link rel="icon" href="/icon.png" type="image/png">
|
<link rel="icon" href="/icon.png" type="image/png">
|
||||||
<link rel="apple-touch-icon" href="/icon-192x192.png">
|
<link rel="apple-touch-icon" href="/icon-192x192.png">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/styles.css">
|
<link rel="stylesheet" href="/assets/styles.993b74f1.css">
|
||||||
<title>TakeYourMeds</title>
|
<title>TakeYourMeds</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -91,9 +92,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast" class="toast" hidden></div>
|
<div id="toast" class="toast" hidden></div>
|
||||||
|
<div id="updateBanner" class="updateBanner" hidden>
|
||||||
|
<span>Neue Version verfügbar</span>
|
||||||
|
<button type="button" class="btn btn-primary" id="updateBannerBtn">Aktualisieren</button>
|
||||||
|
</div>
|
||||||
<div id="confetti" class="confetti" hidden></div>
|
<div id="confetti" class="confetti" hidden></div>
|
||||||
<div id="easterEgg" class="easterEgg" hidden></div>
|
<div id="easterEgg" class="easterEgg" hidden></div>
|
||||||
|
|
||||||
<script type="module" src="/app.js"></script>
|
<script type="module" src="/assets/app.58f3e95c.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+79
-40
@@ -1,20 +1,22 @@
|
|||||||
const CACHE = "tym-v1.0.1";
|
const CACHE = "tym-v1.1.0-914619f3";
|
||||||
const ASSETS = [
|
const ASSETS = [
|
||||||
"/",
|
"/",
|
||||||
"/index.html",
|
"/index.html",
|
||||||
"/styles.css",
|
"/assets/api.89363e2a.js",
|
||||||
"/app.js",
|
"/assets/app.58f3e95c.js",
|
||||||
|
"/assets/auth.a6c28cdb.js",
|
||||||
|
"/assets/badge.30848c28.js",
|
||||||
|
"/assets/offlineQueue.0a9bbfdd.js",
|
||||||
|
"/assets/pwa.b6a6d909.js",
|
||||||
|
"/assets/styles.993b74f1.css",
|
||||||
|
"/assets/ui.47fc1625.js",
|
||||||
|
"/assets/updates.57073981.js",
|
||||||
"/manifest.json",
|
"/manifest.json",
|
||||||
"/version.json",
|
|
||||||
"/icon.png",
|
"/icon.png",
|
||||||
|
"/icon-72x72.png",
|
||||||
"/icon-192x192.png",
|
"/icon-192x192.png",
|
||||||
"/icon-512x512.png",
|
"/icon-512x512.png",
|
||||||
"/lib/api.js",
|
"/version.json"
|
||||||
"/lib/auth.js",
|
|
||||||
"/lib/badge.js",
|
|
||||||
"/lib/offlineQueue.js",
|
|
||||||
"/lib/pwa.js",
|
|
||||||
"/lib/ui.js",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
@@ -31,37 +33,86 @@ self.addEventListener("activate", (event) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.addEventListener("message", (event) => {
|
||||||
|
if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
function isHashedAsset(pathname) {
|
||||||
|
return pathname.startsWith("/assets/");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkOnly(request) {
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkFirst(request, fallbackPath) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) return response;
|
||||||
|
} catch {
|
||||||
|
/* offline */
|
||||||
|
}
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
if (fallbackPath) {
|
||||||
|
const fallback = await caches.match(fallbackPath);
|
||||||
|
if (fallback) return fallback;
|
||||||
|
}
|
||||||
|
return new Response("Offline", { status: 503, statusText: "Offline" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheFirst(request) {
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) {
|
||||||
|
const cache = await caches.open(CACHE);
|
||||||
|
await cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
const { request } = event;
|
const { request } = event;
|
||||||
if (request.method !== "GET") return;
|
if (request.method !== "GET") return;
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.origin !== self.location.origin) return;
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// sw.js: never intercept — browser must revalidate with server
|
||||||
|
if (url.pathname === "/sw.js") return;
|
||||||
|
|
||||||
if (url.pathname.startsWith("/api/")) {
|
if (url.pathname.startsWith("/api/")) {
|
||||||
event.respondWith(fetch(request).catch(() => new Response(JSON.stringify({ offline: true }), {
|
event.respondWith(
|
||||||
status: 503, headers: { "Content-Type": "application/json" },
|
fetch(request).catch(() => new Response(JSON.stringify({ offline: true }), {
|
||||||
})));
|
status: 503,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/version.json") {
|
||||||
|
event.respondWith(networkOnly(request));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.mode === "navigate") {
|
if (request.mode === "navigate") {
|
||||||
event.respondWith(
|
event.respondWith(networkFirst(request, "/index.html"));
|
||||||
fetch(request).catch(() => caches.match("/index.html"))
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.respondWith(
|
if (isHashedAsset(url.pathname)) {
|
||||||
caches.match(request, { ignoreSearch: true }).then((cached) =>
|
event.respondWith(cacheFirst(request));
|
||||||
cached || fetch(request).then((resp) => {
|
return;
|
||||||
if (resp.ok) {
|
|
||||||
const clone = resp.clone();
|
|
||||||
caches.open(CACHE).then((c) => c.put(request, clone));
|
|
||||||
}
|
}
|
||||||
return resp;
|
|
||||||
})
|
if (url.pathname === "/index.html" || url.pathname === "/manifest.json") {
|
||||||
)
|
event.respondWith(networkFirst(request));
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icons and other static root files
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("push", (event) => {
|
self.addEventListener("push", (event) => {
|
||||||
@@ -89,31 +140,19 @@ self.addEventListener("push", (event) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function apiPost(path, body) {
|
|
||||||
const clients = await self.clients.matchAll({ type: "window" });
|
|
||||||
for (const client of clients) {
|
|
||||||
client.postMessage({ type: "API_ACTION", path, body });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.addEventListener("notificationclick", (event) => {
|
self.addEventListener("notificationclick", (event) => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
const slotId = event.notification.data?.slot_id;
|
const slotId = event.notification.data?.slot_id;
|
||||||
const action = event.action;
|
const action = event.action;
|
||||||
|
|
||||||
if (action === "take" && slotId) {
|
if (action === "take" && slotId) {
|
||||||
event.waitUntil(
|
event.waitUntil(self.clients.openWindow(`/?action=take&slot=${slotId}`));
|
||||||
self.clients.openWindow(`/?action=take&slot=${slotId}`)
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((action === "snooze15" || action === "snooze30") && slotId) {
|
if ((action === "snooze15" || action === "snooze30") && slotId) {
|
||||||
const minutes = action === "snooze15" ? 15 : 30;
|
const minutes = action === "snooze15" ? 15 : 30;
|
||||||
event.waitUntil(
|
event.waitUntil(self.clients.openWindow(`/?action=snooze&slot=${slotId}&minutes=${minutes}`));
|
||||||
self.clients.openWindow(`/?action=snooze&slot=${slotId}&minutes=${minutes}`)
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"buildTime": "2026-06-09T19:02:13.248375Z",
|
"buildTime": "2026-07-08T06:50:31.880953Z",
|
||||||
"buildHash": "92521fc3cbd964bd"
|
"buildHash": "914619f3"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "takeyourmeds"
|
name = "takeyourmeds"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
description = "Medikamenten-PWA mit Push-Erinnerungen und sarkastischem Humor"
|
description = "Medikamenten-PWA mit Push-Erinnerungen und sarkastischem Humor"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from server.push import save_subscription, send_slot_reminder
|
|||||||
from server.scheduler import schedule_snooze, start_scheduler
|
from server.scheduler import schedule_snooze, start_scheduler
|
||||||
from server.settings import settings
|
from server.settings import settings
|
||||||
from server.slots import build_today
|
from server.slots import build_today
|
||||||
|
from server.static_cache import CacheControlMiddleware
|
||||||
from server.stats import build_history, check_milestones, compute_stats, MILESTONE_DEFS
|
from server.stats import build_history, check_milestones, compute_stats, MILESTONE_DEFS
|
||||||
|
|
||||||
NO_STORE = {"Cache-Control": "no-store"}
|
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 = FastAPI(title="TakeYourMeds", version=settings.app_version, lifespan=lifespan)
|
||||||
|
app.add_middleware(CacheControlMiddleware)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/pin")
|
@app.post("/api/auth/pin")
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ class Settings(BaseSettings):
|
|||||||
messages_path: str = "messages.yaml"
|
messages_path: str = "messages.yaml"
|
||||||
|
|
||||||
public_dir: str = "public"
|
public_dir: str = "public"
|
||||||
app_version: str = "1.0.0"
|
app_version: str = "1.1.0"
|
||||||
|
|
||||||
|
|
||||||
settings = Settings() # type: ignore[call-arg]
|
settings = Settings() # type: ignore[call-arg]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build frontend assets with content hashes for cache-immutable PWA deploy."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tomllib
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
FRONTEND = ROOT / "frontend"
|
||||||
|
PUBLIC = ROOT / "public"
|
||||||
|
ASSETS_DIR = PUBLIC / "assets"
|
||||||
|
|
||||||
|
HASH_LEN = 8
|
||||||
|
IMPORT_RE = re.compile(r'''from\s+["'](\./(?:lib/)?[^"']+\.js)["']''')
|
||||||
|
|
||||||
|
STATIC_COPY = ("manifest.json",)
|
||||||
|
STATIC_ICONS = (
|
||||||
|
"icon.png",
|
||||||
|
"icon-72x72.png",
|
||||||
|
"icon-192x192.png",
|
||||||
|
"icon-512x512.png",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def short_hash(content: bytes) -> str:
|
||||||
|
return hashlib.sha256(content).hexdigest()[:HASH_LEN]
|
||||||
|
|
||||||
|
|
||||||
|
def js_sources() -> list[tuple[str, str]]:
|
||||||
|
sources: list[tuple[str, str]] = [("app.js", "app")]
|
||||||
|
lib_dir = FRONTEND / "lib"
|
||||||
|
if lib_dir.is_dir():
|
||||||
|
for path in sorted(lib_dir.glob("*.js")):
|
||||||
|
sources.append((f"lib/{path.name}", path.stem))
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
def read_version() -> str:
|
||||||
|
pyproject = ROOT / "pyproject.toml"
|
||||||
|
with pyproject.open("rb") as fh:
|
||||||
|
data = tomllib.load(fh)
|
||||||
|
return str(data["project"]["version"])
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_imports(source: str, stem_to_filename: dict[str, str]) -> str:
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
import_path = match.group(1)
|
||||||
|
stem = Path(import_path).stem
|
||||||
|
hashed = stem_to_filename.get(stem)
|
||||||
|
if not hashed:
|
||||||
|
raise ValueError(f"Unknown import stem {stem!r} in {import_path!r}")
|
||||||
|
quote = match.group(0).split("from", 1)[1].strip()[0]
|
||||||
|
return f'from {quote}./{hashed}{quote}'
|
||||||
|
|
||||||
|
return IMPORT_RE.sub(replace, source)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_assets_dir() -> None:
|
||||||
|
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
for path in ASSETS_DIR.iterdir():
|
||||||
|
if path.name == ".gitkeep":
|
||||||
|
continue
|
||||||
|
if path.is_file():
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def copy_static_files() -> list[str]:
|
||||||
|
copied: list[str] = []
|
||||||
|
for name in STATIC_COPY:
|
||||||
|
src = FRONTEND / name
|
||||||
|
if not src.is_file():
|
||||||
|
raise FileNotFoundError(f"Missing frontend source: {src}")
|
||||||
|
shutil.copy2(src, PUBLIC / name)
|
||||||
|
copied.append(f"/{name}")
|
||||||
|
|
||||||
|
for name in STATIC_ICONS:
|
||||||
|
src = FRONTEND / name
|
||||||
|
if src.is_file():
|
||||||
|
shutil.copy2(src, PUBLIC / name)
|
||||||
|
copied.append(f"/{name}")
|
||||||
|
return copied
|
||||||
|
|
||||||
|
|
||||||
|
def build() -> dict[str, object]:
|
||||||
|
if not FRONTEND.is_dir():
|
||||||
|
raise FileNotFoundError(f"Frontend source directory not found: {FRONTEND}")
|
||||||
|
|
||||||
|
PUBLIC.mkdir(parents=True, exist_ok=True)
|
||||||
|
clean_assets_dir()
|
||||||
|
|
||||||
|
hashed_assets: dict[str, str] = {}
|
||||||
|
stem_to_filename: dict[str, str] = {}
|
||||||
|
file_hashes: list[str] = []
|
||||||
|
|
||||||
|
styles_src = FRONTEND / "styles.css"
|
||||||
|
if not styles_src.is_file():
|
||||||
|
raise FileNotFoundError(f"Missing frontend source: {styles_src}")
|
||||||
|
styles_bytes = styles_src.read_bytes()
|
||||||
|
styles_hash = short_hash(styles_bytes)
|
||||||
|
styles_filename = f"styles.{styles_hash}.css"
|
||||||
|
(ASSETS_DIR / styles_filename).write_bytes(styles_bytes)
|
||||||
|
hashed_assets["styles"] = f"/assets/{styles_filename}"
|
||||||
|
file_hashes.append(styles_hash)
|
||||||
|
|
||||||
|
for rel_path, stem in js_sources():
|
||||||
|
src = FRONTEND / rel_path
|
||||||
|
if not src.is_file():
|
||||||
|
raise FileNotFoundError(f"Missing frontend source: {src}")
|
||||||
|
content = src.read_text(encoding="utf-8")
|
||||||
|
content_hash = short_hash(content.encode("utf-8"))
|
||||||
|
filename = f"{stem}.{content_hash}.js"
|
||||||
|
stem_to_filename[stem] = filename
|
||||||
|
file_hashes.append(content_hash)
|
||||||
|
|
||||||
|
for rel_path, stem in js_sources():
|
||||||
|
src = FRONTEND / rel_path
|
||||||
|
content = rewrite_imports(src.read_text(encoding="utf-8"), stem_to_filename)
|
||||||
|
filename = stem_to_filename[stem]
|
||||||
|
(ASSETS_DIR / filename).write_text(content, encoding="utf-8")
|
||||||
|
hashed_assets[stem] = f"/assets/{filename}"
|
||||||
|
|
||||||
|
build_hash = short_hash("".join(file_hashes).encode("utf-8"))
|
||||||
|
version = read_version()
|
||||||
|
build_time = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
cache_version = f"tym-v{version}-{build_hash}"
|
||||||
|
|
||||||
|
static_paths = copy_static_files()
|
||||||
|
asset_paths = sorted(hashed_assets.values())
|
||||||
|
sw_assets = ["/", "/index.html", *asset_paths, *static_paths, "/version.json"]
|
||||||
|
|
||||||
|
index_template = (FRONTEND / "index.template.html").read_text(encoding="utf-8")
|
||||||
|
index_html = (
|
||||||
|
index_template.replace("{{STYLES_HREF}}", hashed_assets["styles"])
|
||||||
|
.replace("{{APP_SCRIPT_SRC}}", hashed_assets["app"])
|
||||||
|
.replace("{{BUILD_HASH}}", build_hash)
|
||||||
|
)
|
||||||
|
(PUBLIC / "index.html").write_text(index_html, encoding="utf-8")
|
||||||
|
|
||||||
|
sw_template = (FRONTEND / "sw.template.js").read_text(encoding="utf-8")
|
||||||
|
sw_js = (
|
||||||
|
sw_template.replace("{{CACHE_VERSION}}", cache_version)
|
||||||
|
.replace("{{ASSETS_JSON}}", json.dumps(sw_assets, indent=2))
|
||||||
|
)
|
||||||
|
(PUBLIC / "sw.js").write_text(sw_js, encoding="utf-8")
|
||||||
|
|
||||||
|
version_json = {
|
||||||
|
"version": version,
|
||||||
|
"buildTime": build_time,
|
||||||
|
"buildHash": build_hash,
|
||||||
|
}
|
||||||
|
(PUBLIC / "version.json").write_text(
|
||||||
|
json.dumps(version_json, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": version,
|
||||||
|
"buildHash": build_hash,
|
||||||
|
"cacheVersion": cache_version,
|
||||||
|
"assets": hashed_assets,
|
||||||
|
"static": static_paths,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
result = build()
|
||||||
|
print(f"Built frontend v{result['version']} ({result['buildHash']})")
|
||||||
|
print(f"Cache: {result['cacheVersion']}")
|
||||||
|
for stem, url in sorted(result["assets"].items()):
|
||||||
|
print(f" {stem}: {url}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user