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
-189
View File
@@ -1,189 +0,0 @@
import { api } from "./lib/api.js";
import { getToken, setToken, clearToken, isLoggedIn } from "./lib/auth.js";
import { setStreakBadge } from "./lib/badge.js";
import { enqueue, flushQueue } from "./lib/offlineQueue.js";
import { registerPwa, wirePwaInstall, subscribePush } from "./lib/pwa.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" };
function showScreen(id) {
document.querySelectorAll(".screen").forEach((s) => { s.hidden = s.id !== id; });
}
function wirePinForm() {
const form = document.getElementById("pinForm");
const input = document.getElementById("pinInput");
const errEl = document.getElementById("loginError");
input.addEventListener("input", () => {
const digits = input.value.replace(/\D/g, "").slice(0, 6);
if (input.value !== digits) input.value = digits;
errEl.hidden = true;
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const pin = input.value.trim();
if (pin.length < 4) return;
errEl.hidden = true;
try {
const { token } = await api.login(pin);
setToken(token);
input.value = "";
await initApp();
} catch {
errEl.textContent = "Falscher PIN. Dein Gehirn auch.";
errEl.hidden = false;
input.value = "";
input.focus();
}
});
}
function wireTabs() {
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
document.querySelectorAll(".tabPanel").forEach((p) => p.hidden = true);
tab.classList.add("active");
const panel = document.getElementById(`tab-${tab.dataset.tab}`);
panel.hidden = false;
if (tab.dataset.tab === "history") loadHistory();
});
});
}
async function handleTake(slotId) {
const card = document.querySelector(`[data-slot="${slotId}"]`);
try {
if (!navigator.onLine) {
await enqueue({ slot_id: slotId, status: "taken" });
showToast("Offline gespeichert — wird synchronisiert.");
return;
}
const result = await api.log({ slot_id: slotId, status: "taken" });
card?.classList.add("dopamin");
showConfetti();
showToast(result.message);
if (result.new_milestones?.length) {
for (const m of result.new_milestones) {
showEasterEgg(m.title, "Achievement freigeschaltet!");
}
}
await refreshDashboard(result.stats);
} catch (e) {
await enqueue({ slot_id: slotId, status: "taken" });
showToast("Gespeichert — Sync folgt.");
}
}
async function handleSnooze(slotId, minutes) {
try {
const result = await api.snooze(slotId, minutes);
showToast(result.message || `Snooze +${minutes} Min`);
await refreshDashboard();
} catch (e) {
showToast("Snooze fehlgeschlagen.");
}
}
const MOTIVATION_CACHE_KEY = "medis-motivation-day";
function showMotivationFromCache(day) {
const el = document.getElementById("motivationText");
const cached = JSON.parse(sessionStorage.getItem(MOTIVATION_CACHE_KEY) || "null");
if (cached?.day === day && cached?.text) {
el.textContent = cached.text;
return true;
}
return false;
}
async function loadMotivation(day) {
try {
const motivation = await api.roast();
sessionStorage.setItem(MOTIVATION_CACHE_KEY, JSON.stringify({ day: motivation.day, text: motivation.text }));
document.getElementById("motivationText").textContent = motivation.text;
} catch {
document.getElementById("motivationText").textContent = "Dein Gehirn ist nicht kaputt. Es wartet nur auf den richtigen Treiber.";
}
}
async function refreshDashboard(stats) {
const today = await api.today();
renderSlots(today.slots, handleTake, handleSnooze);
if (!showMotivationFromCache(today.day)) {
document.getElementById("motivationText").textContent = "…";
loadMotivation(today.day);
}
const s = stats || await api.stats();
updateStats(s);
await setStreakBadge(s.streak);
}
async function loadHistory() {
const data = await api.history(90);
renderHeatmap(data.days);
updateStats(data.stats);
}
async function handleDeepLink() {
const params = new URLSearchParams(location.search);
const slot = params.get("slot");
if (params.get("action") === "take" && slot) {
await handleTake(slot);
history.replaceState({}, "", "/");
} else if (params.get("action") === "snooze" && slot) {
const minutes = parseInt(params.get("minutes") || "15", 10);
await handleSnooze(slot, minutes);
history.replaceState({}, "", "/");
}
}
async function initApp() {
showScreen("mainScreen");
wireTabs();
wirePwaInstall();
document.getElementById("logoutBtn").addEventListener("click", () => {
clearToken();
location.reload();
});
document.getElementById("enablePush").addEventListener("click", async () => {
const statusEl = document.getElementById("pushStatus");
try {
const perm = await Notification.requestPermission();
if (perm !== "granted") { statusEl.textContent = "Permission verweigert."; return; }
const { key } = await api.vapidKey();
if (!key) { statusEl.textContent = "VAPID keys nicht konfiguriert."; return; }
const sub = await subscribePush(key);
await api.pushSubscribe(sub);
statusEl.textContent = "Push aktiv! Du wirst dezent erinnert.";
} catch (e) {
statusEl.textContent = e.message || "Push-Setup fehlgeschlagen.";
}
});
await flushQueue((data) => api.log(data));
await refreshDashboard();
await handleDeepLink();
}
navigator.serviceWorker?.addEventListener("message", (event) => {
if (event.data?.type === "SYNC_QUEUE") flushQueue((data) => api.log(data));
});
async function boot() {
registerPwa();
if (isLoggedIn()) {
try { await initApp(); return; } catch { clearToken(); }
}
showScreen("loginScreen");
wirePinForm();
}
boot();
View File
+7 -2
View File
@@ -5,12 +5,13 @@
<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="914619f3">
<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.css">
<link rel="stylesheet" href="/assets/styles.993b74f1.css">
<title>TakeYourMeds</title>
</head>
<body>
@@ -91,9 +92,13 @@
</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.js"></script>
<script type="module" src="/assets/app.58f3e95c.js"></script>
</body>
</html>
-33
View File
@@ -1,33 +0,0 @@
import { getToken, clearToken } from "./auth.js";
async function request(path, options = {}) {
const headers = { "Content-Type": "application/json", ...(options.headers || {}) };
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const resp = await fetch(path, { ...options, headers });
if (resp.status === 401) {
clearToken();
window.location.reload();
throw new Error("Unauthorized");
}
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || resp.statusText);
}
return resp.json();
}
export const api = {
login: (pin) => request("/api/auth/pin", { method: "POST", body: JSON.stringify({ pin }) }),
config: () => request("/api/config"),
today: () => request("/api/today"),
log: (data) => request("/api/log", { method: "POST", body: JSON.stringify(data) }),
history: (days = 90) => request(`/api/history?days=${days}`),
stats: () => request("/api/stats"),
roast: () => request("/api/roast"),
snooze: (slot_id, minutes) => request("/api/snooze", { method: "POST", body: JSON.stringify({ slot_id, minutes }) }),
vapidKey: () => request("/api/vapid-public-key"),
pushSubscribe: (subscription) =>
request("/api/push/subscribe", { method: "POST", body: JSON.stringify({ subscription }) }),
};
-17
View File
@@ -1,17 +0,0 @@
const TOKEN_KEY = "tym_token";
export function getToken() {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
export function isLoggedIn() {
return !!getToken();
}
-12
View File
@@ -1,12 +0,0 @@
export async function setStreakBadge(streak) {
if (!("setAppBadge" in navigator)) return;
try {
if (streak > 0) await navigator.setAppBadge(streak);
else await navigator.clearAppBadge();
} catch { /* unsupported */ }
}
export async function clearBadge() {
if (!("clearAppBadge" in navigator)) return;
try { await navigator.clearAppBadge(); } catch { /* noop */ }
}
-44
View File
@@ -1,44 +0,0 @@
const DB_NAME = "tym-offline";
const STORE = "queue";
function openDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: "id", autoIncrement: true });
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function enqueue(entry) {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).add({ ...entry, queued_at: Date.now() });
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => reject(tx.error);
});
}
export async function flushQueue(apiLog) {
const db = await openDb();
const entries = await new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readonly");
const req = tx.objectStore(STORE).getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
for (const entry of entries) {
try {
await apiLog({ slot_id: entry.slot_id, status: entry.status, source: "offline" });
await new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).delete(entry.id);
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
} catch { break; }
}
db.close();
}
-68
View File
@@ -1,68 +0,0 @@
let deferredInstallPrompt = null;
function isStandalone() {
return window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true;
}
export function wirePwaInstall({ sectionId = "pwaInstallSection", buttonId = "installPwa" } = {}) {
const section = document.getElementById(sectionId);
const button = document.getElementById(buttonId);
if (!section || !button) return;
const hide = () => { section.hidden = true; };
if (isStandalone()) { hide(); return; }
hide();
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
deferredInstallPrompt = e;
section.hidden = false;
});
window.addEventListener("appinstalled", () => {
deferredInstallPrompt = null;
hide();
});
button.addEventListener("click", async () => {
if (!deferredInstallPrompt) return;
deferredInstallPrompt.prompt();
await deferredInstallPrompt.userChoice;
deferredInstallPrompt = null;
hide();
});
}
export async function registerPwa() {
if (!("serviceWorker" in navigator)) return;
try {
await navigator.serviceWorker.register("/sw.js");
if ("sync" in ServiceWorkerRegistration.prototype) {
navigator.serviceWorker.ready.then((reg) => {
reg.sync?.register("sync-logs").catch(() => {});
});
}
} catch (e) {
console.error("SW registration failed:", e);
}
}
export async function subscribePush(vapidPublicKey) {
if (!("PushManager" in window)) throw new Error("Push nicht unterstützt");
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
}
return sub.toJSON();
}
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(base64);
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}
-87
View File
@@ -1,87 +0,0 @@
const STATUS_LABELS = {
upcoming: "Noch nicht",
pending: "Jetzt!",
overdue: "Überfällig",
snoozed: "Snoozed",
taken: "Genommen ✓",
missed: "Verpasst",
};
export function showToast(msg, ms = 3000) {
const el = document.getElementById("toast");
el.textContent = msg;
el.hidden = false;
clearTimeout(el._timer);
el._timer = setTimeout(() => { el.hidden = true; }, ms);
}
export function showConfetti() {
const el = document.getElementById("confetti");
el.hidden = false;
el.classList.add("show");
setTimeout(() => { el.hidden = true; el.classList.remove("show"); }, 650);
}
export function showEasterEgg(title, text) {
const overlay = document.getElementById("easterEgg");
overlay.innerHTML = `
<div class="easterEggInner">
<h2>🏆 ${title}</h2>
<p>${text}</p>
<button type="button" class="btn btn-primary" id="easterClose">Nice</button>
</div>`;
overlay.hidden = false;
overlay.querySelector("#easterClose").addEventListener("click", () => {
overlay.hidden = true;
overlay.innerHTML = "";
});
}
export function renderSlots(slots, onTake, onSnooze) {
const container = document.getElementById("slotCards");
container.innerHTML = slots.map((slot) => {
const taken = slot.status === "taken";
const canTake = ["pending", "overdue", "snoozed", "upcoming"].includes(slot.status);
return `
<div class="slotCard status-${slot.status}" data-slot="${slot.id}">
<div class="slotHeader">
<span class="slotLabel">${slot.label}</span>
<span class="slotTime">${slot.time}</span>
</div>
<div class="slotMeds">${slot.meds.map((m) => `${m.name} ${m.dose}`).join(", ")}</div>
<span class="slotStatus">${STATUS_LABELS[slot.status] || slot.status}</span>
${canTake && !taken ? `
<div class="slotActions">
<button type="button" class="btn btn-primary" data-action="take" data-slot="${slot.id}">Genommen ✓</button>
<button type="button" class="btn btn-snooze" data-action="snooze15" data-slot="${slot.id}">+15 Min</button>
<button type="button" class="btn btn-snooze" data-action="snooze30" data-slot="${slot.id}">+30 Min</button>
</div>` : ""}
</div>`;
}).join("");
container.querySelectorAll("[data-action]").forEach((btn) => {
btn.addEventListener("click", () => {
const slotId = btn.dataset.slot;
const action = btn.dataset.action;
if (action === "take") onTake(slotId);
else if (action === "snooze15") onSnooze(slotId, 15);
else if (action === "snooze30") onSnooze(slotId, 30);
});
});
}
export function renderHeatmap(days) {
const container = document.getElementById("heatmap");
container.innerHTML = days.map((d) =>
`<div class="heatCell ${d.level}" title="${d.day}"></div>`
).join("");
}
export function updateStats(stats) {
document.getElementById("statStreak").textContent = stats.streak;
document.getElementById("statCompliance").textContent = `${stats.compliance_percent}%`;
const windowDays = stats.compliance_window_days ?? 90;
document.getElementById("statComplianceLabel").textContent = windowDays === 1 ? "1 Tag" : `${windowDays} Tage`;
document.getElementById("statTaken").textContent = stats.total_taken;
document.getElementById("streakBadge").textContent = `🔥 ${stats.streak}`;
}
-246
View File
@@ -1,246 +0,0 @@
:root {
--primary: #F12F12;
--primary-dark: #c4250e;
--accent-yellow: #FFD600;
--accent-cyan: #00E5FF;
--accent-purple: #7C4DFF;
--bg: #1a1a2e;
--bg-card: #252545;
--bg-card-hover: #2e2e55;
--text: #f0f0ff;
--text-muted: #9999bb;
--success: #00e676;
--warning: #FFD600;
--danger: #ff5252;
--radius: 20px;
--shadow: 0 8px 32px rgba(241, 47, 18, 0.25);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
font-family: "Fredoka", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
-webkit-tap-highlight-color: transparent;
overscroll-behavior: none;
}
body {
background: linear-gradient(160deg, #1a1a2e 0%, #2a1040 50%, #1a1a2e 100%);
min-height: 100dvh;
}
#app { max-width: 480px; margin: 0 auto; min-height: 100dvh; }
.screen { padding: 1.5rem 1rem 2rem; min-height: 100dvh; }
/* Login */
.loginHero { text-align: center; margin: 2rem 0 1.5rem; }
.loginLogo { border-radius: 28px; box-shadow: var(--shadow); margin-bottom: 1rem; }
.loginHero h1 { font-size: 2rem; color: var(--primary); text-shadow: 0 2px 12px rgba(241,47,18,.5); }
.tagline { color: var(--text-muted); margin-top: .5rem; font-size: .95rem; }
.pinForm {
display: flex; flex-direction: column; gap: .75rem;
max-width: 280px; margin: 1rem auto 0;
}
.pinInput {
width: 100%; padding: .85rem 1rem;
border: 2px solid var(--bg-card-hover); border-radius: var(--radius);
background: var(--bg-card); color: var(--accent-cyan);
font-family: inherit; font-size: 1.5rem; font-weight: 600;
text-align: center; letter-spacing: .5rem;
-moz-appearance: textfield;
}
.pinInput:focus {
outline: none; border-color: var(--accent-cyan);
box-shadow: 0 0 0 3px rgba(0, 229, 255, .2);
}
.pinInput::-webkit-outer-spin-button,
.pinInput::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.pinSubmit { width: 100%; }
.loginError { text-align: center; color: var(--danger); margin-top: 1rem; }
/* Top bar */
.topBar {
display: flex; align-items: center; gap: .75rem;
padding: .5rem 0 1rem;
}
.topBar h1 { flex: 1; font-size: 1.5rem; color: var(--primary); }
.topIcon { border-radius: 10px; }
.streakBadge {
background: linear-gradient(135deg, var(--primary), var(--accent-yellow));
padding: .35rem .75rem; border-radius: 999px;
font-weight: 700; font-size: .9rem; color: #1a1a2e;
}
/* Tabs */
.tabNav {
display: flex; gap: .5rem; margin-bottom: 1.25rem;
background: var(--bg-card); border-radius: var(--radius); padding: .35rem;
}
.tab {
flex: 1; border: none; background: transparent; color: var(--text-muted);
font-family: inherit; font-size: .95rem; font-weight: 600;
padding: .6rem; border-radius: calc(var(--radius) - 4px); cursor: pointer;
transition: background .15s, color .15s;
}
.tab.active { background: var(--primary); color: white; }
.tabPanel { animation: fadeIn .25s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
/* Cards */
.roastCard, .motivationCard {
background: linear-gradient(135deg, var(--bg-card) 0%, #3a1855 100%);
border: 2px solid var(--accent-purple);
border-radius: var(--radius); padding: 1rem 1.25rem;
margin-bottom: 1.25rem; box-shadow: 0 4px 20px rgba(124,77,255,.2);
}
.roastLabel, .motivationLabel {
font-size: .65rem; text-transform: uppercase; letter-spacing: .06em;
line-height: 1.35;
color: var(--accent-purple); margin-bottom: .5rem; font-weight: 600;
}
.roastCard p, .motivationCard p { line-height: 1.5; font-size: .95rem; }
.slotCards { display: flex; flex-direction: column; gap: 1rem; }
.slotCard {
background: var(--bg-card); border-radius: var(--radius);
padding: 1.25rem; border-left: 5px solid var(--primary);
box-shadow: var(--shadow); transition: transform .2s;
position: relative; overflow: hidden;
}
.slotCard.status-taken { border-left-color: var(--success); opacity: .85; }
.slotCard.status-pending { border-left-color: var(--warning); animation: pulse 2s infinite; }
.slotCard.status-overdue { border-left-color: var(--danger); }
.slotCard.status-snoozed { border-left-color: var(--accent-cyan); }
.slotCard.status-upcoming { border-left-color: var(--text-muted); opacity: .7; }
.slotCard.status-missed { border-left-color: var(--danger); opacity: .7; }
@keyframes pulse {
0%, 100% { box-shadow: 0 8px 32px rgba(255,214,0,.15); }
50% { box-shadow: 0 8px 32px rgba(255,214,0,.4); }
}
.slotCard.dopamin { animation: dopaminDrop .6s ease; }
@keyframes dopaminDrop {
0% { transform: scale(1); }
30% { transform: scale(1.06); }
60% { transform: scale(.98); }
100% { transform: scale(1); }
}
.slotHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; }
.slotLabel { font-size: 1.2rem; font-weight: 700; }
.slotTime { color: var(--text-muted); font-size: .9rem; }
.slotMeds { color: var(--accent-cyan); margin-bottom: .75rem; font-size: .95rem; }
.slotStatus {
display: inline-block; padding: .2rem .6rem; border-radius: 999px;
font-size: .75rem; font-weight: 600; text-transform: uppercase;
}
.status-taken .slotStatus { background: rgba(0,230,118,.2); color: var(--success); }
.status-pending .slotStatus { background: rgba(255,214,0,.2); color: var(--warning); }
.status-overdue .slotStatus { background: rgba(255,82,82,.2); color: var(--danger); }
.status-snoozed .slotStatus { background: rgba(0,229,255,.2); color: var(--accent-cyan); }
.status-upcoming .slotStatus { background: rgba(153,153,187,.2); color: var(--text-muted); }
.status-missed .slotStatus { background: rgba(255,82,82,.15); color: var(--danger); }
.slotActions { display: flex; gap: .5rem; margin-top: .75rem; flex-wrap: wrap; }
.btn {
border: none; border-radius: 999px; font-family: inherit;
font-weight: 600; font-size: .95rem; padding: .7rem 1.25rem;
cursor: pointer; transition: transform .1s, opacity .15s;
}
.btn:active { transform: scale(.95); }
.btn-primary { background: var(--primary); color: white; flex: 1; }
.btn-secondary { background: var(--bg-card-hover); color: var(--text); }
.btn-accent { background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple)); color: white; width: 100%; }
.btn-danger { background: transparent; color: var(--danger); border: 2px solid var(--danger); width: 100%; }
.btn-snooze { background: rgba(0,229,255,.15); color: var(--accent-cyan); font-size: .85rem; padding: .5rem .9rem; }
/* Stats */
.statsRow { display: grid; grid-template-columns: repeat(3, 1fr); gap: .75rem; margin-bottom: 1.25rem; }
.statBox {
background: var(--bg-card); border-radius: var(--radius);
padding: 1rem; text-align: center;
border-top: 3px solid var(--primary);
}
.statNum { font-size: 1.75rem; font-weight: 700; color: var(--accent-yellow); }
.statLabel { font-size: .75rem; color: var(--text-muted); margin-top: .25rem; }
.sectionTitle { font-size: 1rem; color: var(--text-muted); margin-bottom: .75rem; }
/* Heatmap */
.heatmap {
display: grid; grid-template-columns: repeat(15, 1fr); gap: 3px;
margin-bottom: 2rem;
}
.heatCell {
aspect-ratio: 1; border-radius: 4px; background: rgba(255,255,255,.06);
cursor: default; transition: transform .1s;
}
.heatCell.good { background: var(--success); opacity: .85; }
.heatCell.partial { background: var(--warning); opacity: .7; }
.heatCell.bad { background: var(--danger); opacity: .7; }
.heatCell.none { background: rgba(255,255,255,.06); }
.heatCell:hover { transform: scale(1.3); z-index: 1; }
/* Settings */
.settingsGroup { margin-bottom: 1.5rem; }
.settingsGroup h2 { font-size: 1rem; margin-bottom: .5rem; color: var(--accent-cyan); }
.settingsHint { color: var(--text-muted); font-size: .85rem; margin-bottom: .75rem; line-height: 1.4; }
.settingsStatus { color: var(--text-muted); font-size: .85rem; margin-top: .5rem; }
/* Toast */
.toast {
position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%);
background: var(--bg-card); border: 2px solid var(--primary);
border-radius: var(--radius); padding: .85rem 1.25rem;
max-width: 90%; z-index: 1000; font-size: .9rem;
box-shadow: var(--shadow); animation: slideUp .3s ease;
}
@keyframes slideUp { from { opacity: 0; transform: translateX(-50%) translateY(20px); } }
/* Confetti */
.confetti {
position: fixed; inset: 0; pointer-events: none; z-index: 999;
background: radial-gradient(circle at 20% 50%, var(--primary) 0%, transparent 50%),
radial-gradient(circle at 80% 30%, var(--accent-yellow) 0%, transparent 40%),
radial-gradient(circle at 50% 80%, var(--accent-cyan) 0%, transparent 45%);
opacity: 0; transition: opacity .3s;
}
.confetti.show { opacity: .6; animation: confettiFade .6s ease forwards; }
@keyframes confettiFade {
0% { opacity: .7; }
100% { opacity: 0; }
}
/* Easter egg overlay */
.easterEgg {
position: fixed; inset: 0; background: rgba(0,0,0,.7);
z-index: 2000;
}
.easterEgg[hidden] {
display: none !important;
pointer-events: none;
}
.easterEgg:not([hidden]) {
display: flex; align-items: center; justify-content: center;
animation: fadeIn .3s ease;
}
.easterEggInner {
background: linear-gradient(135deg, var(--primary), var(--accent-purple));
border-radius: var(--radius); padding: 2rem; text-align: center;
max-width: 85%; box-shadow: 0 20px 60px rgba(0,0,0,.5);
}
.easterEggInner h2 { font-size: 1.5rem; margin-bottom: .75rem; }
.easterEggInner p { margin-bottom: 1.25rem; line-height: 1.5; }
+82 -43
View File
@@ -1,20 +1,22 @@
const CACHE = "tym-v1.0.1";
const CACHE = "tym-v1.1.0-914619f3";
const ASSETS = [
"/",
"/index.html",
"/styles.css",
"/app.js",
"/assets/api.89363e2a.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",
"/version.json",
"/icon.png",
"/icon-72x72.png",
"/icon-192x192.png",
"/icon-512x512.png",
"/lib/api.js",
"/lib/auth.js",
"/lib/badge.js",
"/lib/offlineQueue.js",
"/lib/pwa.js",
"/lib/ui.js",
"/version.json"
];
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) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) 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;
}
// sw.js: never intercept — browser must revalidate with server
if (url.pathname === "/sw.js") return;
if (request.mode === "navigate") {
if (url.pathname.startsWith("/api/")) {
event.respondWith(
fetch(request).catch(() => caches.match("/index.html"))
fetch(request).catch(() => new Response(JSON.stringify({ offline: true }), {
status: 503,
headers: { "Content-Type": "application/json" },
}))
);
return;
}
event.respondWith(
caches.match(request, { ignoreSearch: true }).then((cached) =>
cached || fetch(request).then((resp) => {
if (resp.ok) {
const clone = resp.clone();
caches.open(CACHE).then((c) => c.put(request, clone));
}
return resp;
})
)
);
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) => {
@@ -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) => {
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}`)
);
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}`)
);
event.waitUntil(self.clients.openWindow(`/?action=snooze&slot=${slotId}&minutes=${minutes}`));
return;
}
+4 -4
View File
@@ -1,5 +1,5 @@
{
"version": "1.0.0",
"buildTime": "2026-06-09T19:02:13.248375Z",
"buildHash": "92521fc3cbd964bd"
}
"version": "1.1.0",
"buildTime": "2026-07-08T06:50:31.880953Z",
"buildHash": "914619f3"
}