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:
-189
@@ -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();
|
||||
+7
-2
@@ -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>
|
||||
|
||||
@@ -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 }) }),
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user