debug line test
This commit is contained in:
+173
@@ -0,0 +1,173 @@
|
||||
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";
|
||||
|
||||
let pinBuffer = "";
|
||||
|
||||
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 wirePinPad() {
|
||||
const pad = document.getElementById("pinPad");
|
||||
const keys = ["1","2","3","4","5","6","7","8","9","⌫","0","✓"];
|
||||
pad.innerHTML = keys.map((k) =>
|
||||
`<button type="button" class="pinKey${k.length > 1 ? " wide" : ""}" data-key="${k}">${k}</button>`
|
||||
).join("");
|
||||
|
||||
pad.addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("[data-key]");
|
||||
if (!btn) return;
|
||||
const key = btn.dataset.key;
|
||||
const errEl = document.getElementById("loginError");
|
||||
errEl.hidden = true;
|
||||
|
||||
if (key === "⌫") { pinBuffer = pinBuffer.slice(0, -1); }
|
||||
else if (key === "✓") {
|
||||
if (pinBuffer.length < 4) return;
|
||||
try {
|
||||
const { token } = await api.login(pinBuffer);
|
||||
setToken(token);
|
||||
pinBuffer = "";
|
||||
await initApp();
|
||||
} catch {
|
||||
errEl.textContent = "Falscher PIN. Dein Gehirn auch.";
|
||||
errEl.hidden = false;
|
||||
pinBuffer = "";
|
||||
}
|
||||
} else if (pinBuffer.length < 6) {
|
||||
pinBuffer += key;
|
||||
}
|
||||
document.getElementById("pinDisplay").textContent = "•".repeat(pinBuffer.length) || "••••";
|
||||
});
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDashboard(stats) {
|
||||
const [today, roast] = await Promise.all([
|
||||
api.today(),
|
||||
api.roast().catch(() => ({ text: "Dein Gebrain wartet auf Koffein und Medis." })),
|
||||
]);
|
||||
renderSlots(today.slots, handleTake, handleSnooze);
|
||||
document.getElementById("roastText").textContent = roast.text;
|
||||
|
||||
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);
|
||||
const oracle = await api.oracle().catch(() => ({ text: "Das Orakel schweigt. Wahrscheinlich enttäuscht." }));
|
||||
document.getElementById("oracleText").textContent = oracle.text;
|
||||
}
|
||||
|
||||
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");
|
||||
wirePinPad();
|
||||
}
|
||||
|
||||
boot();
|
||||
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 |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 228 KiB |
@@ -0,0 +1,101 @@
|
||||
<!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">
|
||||
<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">
|
||||
<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>
|
||||
<div class="pinDisplay" id="pinDisplay">••••</div>
|
||||
<div class="pinPad" id="pinPad"></div>
|
||||
<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="roastCard" id="roastCard">
|
||||
<div class="roastLabel">Roast of the Day</div>
|
||||
<p id="roastText">Lade Roast…</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">90 Tage</div>
|
||||
</div>
|
||||
<div class="statBox">
|
||||
<div class="statNum" id="statTaken">0</div>
|
||||
<div class="statLabel">Genommen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="oracleCard" id="oracleCard">
|
||||
<div class="oracleLabel">🔮 KI-Orakel</div>
|
||||
<p id="oracleText">Lade Orakel…</p>
|
||||
</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="confetti" class="confetti" hidden></div>
|
||||
<div id="easterEgg" class="easterEgg" hidden></div>
|
||||
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
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"),
|
||||
oracle: () => request("/api/oracle"),
|
||||
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 }) }),
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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 */ }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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)));
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
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}%`;
|
||||
document.getElementById("statTaken").textContent = stats.total_taken;
|
||||
document.getElementById("streakBadge").textContent = `🔥 ${stats.streak}`;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
|
||||
: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; }
|
||||
|
||||
.pinDisplay {
|
||||
text-align: center; font-size: 2rem; letter-spacing: .5rem;
|
||||
margin: 1rem 0; color: var(--accent-cyan); min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.pinPad {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: .75rem;
|
||||
max-width: 280px; margin: 0 auto;
|
||||
}
|
||||
|
||||
.pinKey {
|
||||
aspect-ratio: 1; border: none; border-radius: var(--radius);
|
||||
background: var(--bg-card); color: var(--text);
|
||||
font-family: inherit; font-size: 1.5rem; font-weight: 600;
|
||||
cursor: pointer; transition: transform .1s, background .15s;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.3);
|
||||
}
|
||||
.pinKey:active { transform: scale(.93); background: var(--primary); }
|
||||
.pinKey.wide { grid-column: span 1; font-size: 1rem; }
|
||||
|
||||
.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, .oracleCard {
|
||||
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, .oracleLabel {
|
||||
font-size: .75rem; text-transform: uppercase; letter-spacing: .08em;
|
||||
color: var(--accent-purple); margin-bottom: .5rem; font-weight: 600;
|
||||
}
|
||||
.roastCard p, .oracleCard 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);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 2000; 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; }
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
const CACHE = "tym-v1.0.0";
|
||||
const ASSETS = [
|
||||
"/",
|
||||
"/index.html",
|
||||
"/styles.css",
|
||||
"/app.js",
|
||||
"/manifest.json",
|
||||
"/version.json",
|
||||
"/icon.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",
|
||||
];
|
||||
|
||||
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("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;
|
||||
}
|
||||
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => caches.match("/index.html"))
|
||||
);
|
||||
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;
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
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)
|
||||
);
|
||||
});
|
||||
|
||||
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}`)
|
||||
);
|
||||
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" }));
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"buildTime": "2026-06-09T19:02:13.248375Z",
|
||||
"buildHash": "92521fc3cbd964bd"
|
||||
}
|
||||
Reference in New Issue
Block a user