debug line test

This commit is contained in:
Frank Schwenk
2026-06-09 21:21:52 +02:00
commit eaa019087e
43 changed files with 2666 additions and 0 deletions
+34
View File
@@ -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 }) }),
};
+17
View File
@@ -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();
}
+12
View File
@@ -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 */ }
}
+44
View File
@@ -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();
}
+68
View File
@@ -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)));
}
+84
View File
@@ -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}`;
}