"use strict"; /* =========================================================================== Trainingsplan - Frontend Kern-Entscheidungen: - Das DOM der Heute-Ansicht wird EINMAL gebaut. Tippen schreibt nur in das eigene Set-State-Objekt und loest einen Debounce-Autosave aus. Es gibt keine Re-Renders waehrend der Eingabe -> Fokus/Cursor bleiben intakt. - Aenderungen werden pro Uebung (exerciseKey) nachgetragen. Nur Uebungen mit ungespeicherten Werten werden an den Server geschickt (dirtyEx). - Saetze haben keine IDs im Client: sie werden ueber (exercise, sort) mit dem Server abgeglichen; der Server vergibt stabile DB-IDs. =========================================================================== */ const app = { cfg: null, meta: null, settings: null, me: null, models: {}, // activityId -> SessionModel cards: {}, // activityId -> Karten-Element pickedDay: {}, // activityId -> gewaehlter Zyklus-Tag saveInd: {}, // activityId -> Speicher-Indikator autosaveTimer: {}, flushBusy: false, flushQueuedActivity: null, toastTimer: null, }; const BRAND = "REKORD"; const WD = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"]; const ACCENTS = { amber: "#e0a33c", orange: "#e4582e", red: "#d64541", green: "#3fae5a", teal: "#2aa198", cyan: "#25b8cc", blue: "#4a86c8", violet: "#9a6fd0", pink: "#e0567f", white: "#ffffff", }; const LANG = { de: { tab_today: "Heute", tab_history: "Verlauf", tab_stats: "Statistik", tab_data: "Daten", tab_settings: "Einstellungen", today_header: "Heute · ", start_session: "Session starten", finish_session: "Session abschließen", discard: "Verwerfen", confirm_discard: "Wirklich verwerfen?", note_placeholder: "Notiz (optional)", started: "gestartet ", saved: "gespeichert", saving: "wird gespeichert ...", save_error: "Fehler beim Speichern", save_failed: "Änderungen konnten nicht gespeichert werden. Bitte erneut versuchen.", pick_day: "Tag wählen", add_exercise: "Übung hinzufügen ...", add_set: "Satz hinzufügen", remove_exercise: "Entfernen", set_label: "Set", reset_set: "Reset", set_done_aria: "Satz {n} abhaken", remove_set_aria: "Satz entfernen", note_label: "Notiz", reset_set_title: "Auf letztes Training zurücksetzen", reset_all: "Alles zurücksetzen", reset_all_title: "Alle Sätze auf das letzte abgeschlossene Workout setzen", weight: "Gewicht", reps: "Reps", set_count: "Sätze", start_hint: "Start", info_aria: "Erklärung zu ", in_work: "In Arbeit seit {d} - noch nicht abgeschlossen.", move_today: "Auf heute übertragen", day_missing: "Session-Tag '{d}' ist nicht mehr in config.json.", kind_strength: "Kraft", kind_cardio: "Cardio", no_activities: "Keine Aktivitäten konfiguriert. Siehe config.json.", close: "Schließen", finish: "Fertig", remove_exercise_aria: "Übung aus der Session entfernen", reset_all_done: "Alle Sätze auf das letzte abgeschlossene Workout zurückgesetzt.", session_saved: "Session gespeichert und abgeschlossen.", session_discarded: "Session verworfen.", session_deleted: "Session gelöscht.", session_updated: "Session aktualisiert.", no_prev_measure: "Keine frühere Messung für Satz {n}", empty_label: "leer", edit: "Bearbeiten", delete: "Löschen", sure: "Sicher?", note_prefix: "Notiz: ", time_unknown: "Zeit unbekannt", from_time: "ab ", to_time: " bis ", date: "Datum", start: "Start", end: "Ende", save: "Speichern", cancel: "Abbrechen", set_n: "Satz {n}", no_history: "Noch keine abgeschlossenen Sessions. Im Tab 'Heute' eine Session starten und abschließen.", no_values: "keine Werte", choose_exercise: "Übung wählen", col_date: "Datum", col_best: "Bestes Gewicht", col_reps: "Reps", col_diff: "Diff. zur ersten Messung", no_stats: "Noch keine Messungen für diese Übung.", no_strength: "Keine Kraft-Übungen konfiguriert.", export: "Export", import: "Import", export_json: "Export JSON", export_csv: "Export CSV", import_json: "JSON einspielen", import_csv: "CSV einspielen", data_desc: "Der komplette Stand (auch in Arbeit befindliche Sessions) als JSON oder CSV. CSV-Spalten: session_id, activity, day, date, start_time, end_time, note, status, kind, exercise, set, weight, reps, done sowie die Cardio-Felder aus der config.", import_desc: "Import ergänzt Sessions oder aktualisiert sie anhand ihrer ID. Es wird nichts gelöscht. Für eine Wiederherstellung am besten in einen frischen Stand importieren.", file_first: "Zuerst eine Datei wählen.", import_ok: "{c} Sessions angelegt, {u} aktualisiert.", import_fail: "Import fehlgeschlagen: {m}", appearance: "Darstellung", theme_label: "Farbthema", dark: "Dunkel", light: "Hell", oled: "OLED", accent_label: "Akzentfarbe", accent_aria: "Akzent {k}", language_label: "Sprache", german: "Deutsch", english: "English", behavior: "Verhalten", auto_reset: "Auto-Reset", auto_reset_desc: "Beim Start einer Session das letzte Workout der jeweiligen Kategorie automatisch laden (nur ausgefüllte Sätze).", show_tips: "Übungs-Hinweise", show_tips_desc: "Zeige Hinweise wie '4 Sätze · 8-10 reps · Start 18 kg' neben dem Übungsnamen.", reset_defaults: "Auf Standard zurücksetzen", defaults_reset: "Einstellungen zurückgesetzt.", setting_saved: "Einstellung gespeichert.", account: "Konto", account_desc: "Angemeldet als '{u}'. Kennwort ändern:", change_pw: "Kennwort ändern", current_pw: "Aktuelles Kennwort", logout_label: "Abmelden", session_active: "aktiv", reset_server: "Server zurücksetzen", reset_server_desc: "Löscht alle Daten und Benutzer dieser Instanz. Die App startet danach wie neu (Einrichtungs-Wizard). Gib dein Kennwort ein und tippe RESET zum Bestätigen.", reset_confirm_hint: "Zum Bestätigen RESET eintippen", reset_server_go: "Zurücksetzen", reset_done: "Server zurückgesetzt.", new_pw: "Neues Kennwort", pw_changed: "Kennwort geändert.", users: "Benutzer", setup_users: "Benutzer einrichten", users_solo_desc: "Du bist im Einzelmodus (ohne Anmeldung). Lege den ersten Benutzer an, um Login und mehrere Benutzer zu aktivieren.", first_user: "Ersten Benutzer anlegen", users_desc: "Weitere Benutzer mit eigenen Daten anlegen, Kennwörter zurücksetzen oder Benutzer löschen.", owner: "Besitzer", create: "Anlegen", apply: "Übernehmen", cancel_change: "Änderung abbrechen", user_created: "Benutzer '{u}' angelegt.", user_deleted: "Benutzer gelöscht.", pw_set: "Kennwort von '{u}' gesetzt.", only_user: "Du bist der einzige Benutzer.", welcome: "Willkommen! Wähle, wie du die App nutzen möchtest:", multi_mode: "Mehrbenutzermodus", multi_desc: "Jeder Benutzer hat Benutzername und Kennwort. Der erste Benutzer ist Administrator und kann Kennwörter ändern sowie Benutzer anlegen und löschen - auch für Einzelnutzer von Vorteil.", solo_mode: "Einzelmodus", solo_desc: "Ohne Anmeldung, jeder kann auf die Instanz zugreifen. Der Mehrbenutzermodus kann jederzeit in den Einstellungen aktiviert werden.", login: "Anmelden", username: "Benutzername", password: "Kennwort", login_sub: "Anmelden, um deine Trainingsdaten zu sehen.", lang_step: "Sprache festlegen", lang_server: "Diese Sprache gilt serverweit als Standard.", color_step: "Akzentfarbe festlegen", color_server: "Wird als Standard-Akzent übernommen.", onboard_title: "Persönliche Einstellungen", onboard_desc: "Wähle einmalig Sprache und Akzentfarbe für dein Konto.", back: "Zurück", next: "Weiter", finish_setup: "Einrichtung abschließen", system: "System", oled_toggle: "OLED-Dark", oled_desc: "Reines Schwarz für den dunklen Modus.", appearance_desc: "Theme, Akzentfarbe und OLED-Dark für deine App.", last_workout: "Letztes Training vorbefüllen", last_workout_desc: "Neue Session startet mit dem letzten abgeschlossenen Workout der Kategorie (nur ausgefüllte Sätze).", plan_prefill: "Plan-Gewichte vorbefüllen", plan_prefill_desc: "Neue Session startet mit den Start-Gewichten aus der Config (startKg).", weight_unit: "Gewichtseinheit", start_tab: "Start-Ansicht", week: "Woche", list_view: "Liste", no_sessions_week: "keine Sessions", prog_lang: "Sprache", prog_mode: "Modus", prog_appearance: "Darstellung", reorder_hint: "Übungen per Ziehen sortieren", prev_week: "Vorige Woche", next_week: "Nächste Woche", week_of: "Woche vom", wizard_done: "Alles erledigt - die App ist bereit.", wizard_user_created: "Benutzer '{u}' angelegt.", wizard_lang_hint: "Anleitung, Demo-Daten und der Übungsplan stehen im Repo.", wizard_user_later: "Der Benutzer wird beim Abschließen angelegt - bis dahin kannst du dich umentscheiden.", wizard_admin_hint: "Der erste Benutzer wird Administrator (verwaltet Konten und Kennwörter).", logged_in: "Angemeldet.", user_created_toast: "Benutzer angelegt.", app_load_fail: "App konnte nicht geladen werden: {m}", }, en: { tab_today: "Today", tab_history: "History", tab_stats: "Statistics", tab_data: "Data", tab_settings: "Settings", today_header: "Today · ", start_session: "Start session", finish_session: "Finish session", discard: "Discard", confirm_discard: "Really discard?", note_placeholder: "Note (optional)", started: "started ", saved: "saved", saving: "saving ...", save_error: "Save failed", save_failed: "Could not save changes. Please try again.", pick_day: "Choose day", add_exercise: "Add exercise ...", add_set: "Add set", remove_exercise: "Remove", set_label: "Set", reset_set: "Reset", set_done_aria: "Check off set {n}", remove_set_aria: "Remove set", note_label: "Note", reset_set_title: "Reset to last workout", reset_all: "Reset all", reset_all_title: "Reset all sets to the last completed workout", weight: "Weight", reps: "Reps", set_count: "sets", start_hint: "Start", info_aria: "About ", in_work: "In progress since {d} - not finished yet.", move_today: "Move to today", day_missing: "Session day '{d}' is no longer in config.json.", kind_strength: "Strength", kind_cardio: "Cardio", no_activities: "No activities configured. See config.json.", close: "Close", finish: "Done", remove_exercise_aria: "Remove exercise from session", reset_all_done: "All sets reset to the last completed workout.", session_saved: "Session saved and finished.", session_discarded: "Session discarded.", session_deleted: "Session deleted.", session_updated: "Session updated.", no_prev_measure: "No previous measurement for set {n}", empty_label: "empty", edit: "Edit", delete: "Delete", sure: "Sure?", note_prefix: "Note: ", time_unknown: "time unknown", from_time: "from ", to_time: " to ", date: "Date", start: "Start", end: "End", save: "Save", cancel: "Cancel", set_n: "Set {n}", no_history: "No completed sessions yet. Start and finish a session in the 'Today' tab.", no_values: "no values", choose_exercise: "Choose exercise", col_date: "Date", col_best: "Best weight", col_reps: "Reps", col_diff: "Diff from first measurement", no_stats: "No measurements for this exercise yet.", no_strength: "No strength exercises configured.", export: "Export", import: "Import", export_json: "Export JSON", export_csv: "Export CSV", import_json: "Import JSON", import_csv: "Import CSV", data_desc: "Export the complete state (including sessions in progress) as JSON or CSV. CSV columns: session_id, activity, day, date, start_time, end_time, note, status, kind, exercise, set, weight, reps, done plus the cardio fields from the config.", import_desc: "Import adds sessions or updates them by their ID. Nothing is deleted. For a restore, import into a fresh state.", file_first: "Choose a file first.", import_ok: "{c} sessions created, {u} updated.", import_fail: "Import failed: {m}", appearance: "Appearance", theme_label: "Color theme", dark: "Dark", light: "Light", oled: "OLED", accent_label: "Accent color", accent_aria: "Accent {k}", language_label: "Language", german: "German", english: "English", behavior: "Behavior", auto_reset: "Auto-reset", auto_reset_desc: "When starting a session, automatically load the last workout of the category (only filled sets).", show_tips: "Exercise hints", show_tips_desc: "Show hints like '4 sets · 8-10 reps · Start 18 kg' next to the exercise name.", reset_defaults: "Reset to defaults", defaults_reset: "Settings reset.", setting_saved: "Setting saved.", account: "Account", account_desc: "Logged in as '{u}'. Change password:", change_pw: "Change password", current_pw: "Current password", logout_label: "Log out", session_active: "active", reset_server: "Reset server", reset_server_desc: "Deletes all data and users of this instance. The app starts fresh afterwards (setup wizard). Enter your password and type RESET to confirm.", reset_confirm_hint: "Type RESET to confirm", reset_server_go: "Reset", reset_done: "Server reset.", new_pw: "New password", pw_changed: "Password changed.", users: "Users", setup_users: "Set up users", users_solo_desc: "You are in single-user mode (no login). Create the first user to enable login and multiple users.", first_user: "Create first user", users_desc: "Create additional users with their own data, reset passwords, or delete users.", owner: "Owner", create: "Create", apply: "Apply", cancel_change: "Cancel change", user_created: "User '{u}' created.", user_deleted: "User deleted.", pw_set: "Password for '{u}' set.", only_user: "You are the only user.", welcome: "Welcome! Choose how you want to use the app:", multi_mode: "Multi-user mode", multi_desc: "Each user has a username and password. The first user is the administrator and can change passwords as well as create and delete users - also an advantage for single users.", solo_mode: "Single-user mode", solo_desc: "No login; anyone can access the instance. Multi-user mode can be activated anytime in the settings.", login: "Log in", username: "Username", password: "Password", login_sub: "Log in to see your training data.", lang_step: "Set language", lang_server: "This language is the server-wide default.", color_step: "Set accent color", color_server: "Will be used as the default accent.", onboard_title: "Personal settings", onboard_desc: "Choose language and accent color once for your account.", back: "Back", next: "Next", finish_setup: "Finish setup", system: "System", oled_toggle: "OLED dark", oled_desc: "Pure black for the dark mode.", appearance_desc: "Theme, accent color and OLED dark for your app.", last_workout: "Prefill last workout", last_workout_desc: "New sessions start with the last completed workout of the category (only filled sets).", plan_prefill: "Prefill plan weights", plan_prefill_desc: "New sessions start with the start weights from the config (startKg).", weight_unit: "Weight unit", start_tab: "Start view", week: "Week", list_view: "List", no_sessions_week: "no sessions", prog_lang: "Language", prog_mode: "Mode", prog_appearance: "Appearance", reorder_hint: "Drag to reorder exercises", prev_week: "Previous week", next_week: "Next week", week_of: "Week of", wizard_done: "All done - the app is ready.", wizard_user_created: "User '{u}' created.", wizard_lang_hint: "Guide, demo data and the exercise plan live in the repo.", wizard_user_later: "The user will be created when you finish - you can still change your mind until then.", wizard_admin_hint: "The first user becomes the administrator (manages accounts and passwords).", logged_in: "Logged in.", user_created_toast: "User created.", app_load_fail: "Could not load the app: {m}", }, }; function lang() { const l = app.settings && app.settings.language; return (l === "en") ? "en" : "de"; } function t(key, vars) { const table = LANG[lang()] || LANG.de; let s = table[key]; if (s === undefined) s = LANG.de[key] || key; if (vars) { for (const k of Object.keys(vars)) { s = s.replace("{" + k + "}", String(vars[k])); } } return s; } /* Uebersetze haeufige Server-Fehlermeldungen (de -> aktuelle Sprache). */ const ERR_DE = { "Benutzername oder Passwort falsch": ["login_fail_de", "login_fail_en"], "Aktuelles Passwort ist falsch": ["current_pw_wrong_de", "current_pw_wrong_en"], "Passwort zu kurz (mindestens 4 Zeichen)": ["pw_short_de", "pw_short_en"], "Benutzername zu kurz (mindestens 2 Zeichen)": ["user_short_de", "user_short_en"], "Benutzername ist bereits vergeben": ["user_taken_de", "user_taken_en"], "Registrierung geschlossen, es existiert bereits ein Benutzer": ["reg_closed_de", "reg_closed_en"], "Anmeldung erforderlich": ["auth_required_de", "auth_required_en"], "nur der erste Benutzer": ["owner_only_de", "owner_only_en"], "benutzer nicht gefunden": ["user_not_found_de", "user_not_found_en"], }; const ERR_L10N = { de: { login_fail: "Benutzername oder Kennwort inkorrekt", current_pw_wrong: "Aktuelles Kennwort ist falsch", pw_short: "Kennwort zu kurz (mindestens 4 Zeichen)", user_short: "Benutzername zu kurz (mindestens 2 Zeichen)", user_taken: "Benutzername ist bereits vergeben", reg_closed: "Registrierung geschlossen, es existiert bereits ein Benutzer", auth_required: "Anmeldung erforderlich", owner_only: "nur der erste Benutzer", user_not_found: "Benutzer nicht gefunden", }, en: { login_fail: "Incorrect username or password", current_pw_wrong: "Current password is incorrect", pw_short: "Password too short (at least 4 characters)", user_short: "Username too short (at least 2 characters)", user_taken: "Username is already taken", reg_closed: "Registration is closed, a user already exists", auth_required: "Login required", owner_only: "only the first user", user_not_found: "User not found", }, }; function eMsg(e) { const raw = (e && e.message) || String(e); const map = ERR_DE[raw]; if (map) return ERR_L10N[lang()][map[0].replace("_de", "")]; return raw; } /* --------------------------------------------------------------------------- Kleine Helfer ------------------------------------------------------------------------- */ function $(sel, root) { return (root || document).querySelector(sel); } function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); } function el(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text !== undefined && text !== null) n.textContent = text; return n; } async function api(path, opts) { opts = opts || {}; const headers = opts.headers || {}; const token = localStorage.getItem("tp_token"); if (token) headers.Authorization = "Bearer " + token; opts.headers = headers; const res = await fetch(path, opts); if (!res.ok) { let msg = "HTTP " + res.status; try { const j = await res.json(); if (j && j.error) msg = j.error; } catch (_) {} const err = new Error(msg); err.status = res.status; throw err; } return res.json(); } function jsonOpts(method, body) { return { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }; } function toast(msg, kind) { const t = $("#toast"); t.textContent = msg; t.className = "toast" + (kind ? " is-" + kind : ""); t.hidden = false; clearTimeout(app.toastTimer); app.toastTimer = setTimeout(() => { t.hidden = true; }, 2800); } function flashError(e) { toast(eMsg(e), "error"); } function fmtDate(iso) { const p = String(iso).split("-"); return p.length === 3 ? p[2] + "." + p[1] + "." + p[0] : String(iso); } function fmtDateLong(iso) { const p = String(iso).split("-"); if (p.length !== 3) return String(iso); const dt = new Date(+p[0], +p[1] - 1, +p[2]); return WD[dt.getDay()] + " " + fmtDate(iso); } function fmtKg(v) { return String(Number(v)).replace(".", ","); } function cfgActivity(id) { return app.cfg.activities.find(a => a.id === id) || null; } function findDay(a, dayKey) { return a.cycle ? a.cycle.days.find(d => d.key === dayKey) || null : null; } function allExercisesMap(a) { const map = {}; if (!a.cycle) return map; for (const d of a.cycle.days) { for (const ex of d.exercises) map[ex.key] = ex; } return map; } /* --------------------------------------------------------------------------- SessionModel: der gesamte Client-Zustand einer laufenden Session. Jede Uebung hat ihr eigenes Set-Array; nichts wird global neu gebaut. exKeys ist die geordnete Liste der Uebungen dieser Session (Tag + manuell hinzugefuegte aus anderen Tagen). ------------------------------------------------------------------------- */ function makeModel(a, sess) { const m = { id: sess.id, activity: a.id, day: sess.day || "", date: sess.date, start_time: sess.start_time || "", note: sess.note || "", fields: sess.fields || {}, status: sess.status || "active", exercises: {}, // exerciseKey -> [ {id,weight,reps,done} ] exKeys: [], // geordnete Uebungs-Schluessel dieser Session exBlocks: {}, // exerciseKey -> Block-Element exChecks: {}, // exerciseKey -> [check-Button] exByKey: {}, // exerciseKey -> Konfig-Uebung dirtyEx: new Set(), // Uebungen mit ungespeicherten Aenderungen metaDirty: false, }; m.exByKey = allExercisesMap(a); const byEx = {}; for (const e of sess.entries || []) { (byEx[e.exercise] = byEx[e.exercise] || []).push(e); } const keys = []; const configEx = {}; const day = findDay(a, m.day); if (day) { for (const ex of day.exercises) { configEx[ex.key] = ex; keys.push(ex.key); } } for (const k of Object.keys(byEx)) if (!keys.includes(k)) keys.push(k); m.exKeys = keys; // Manuell sortierte Uebungen (Drag & Drop) wiederherstellen if (Array.isArray(m.fields.exerciseOrder) && m.fields.exerciseOrder.length) { const ordered = m.fields.exerciseOrder.filter(k => keys.includes(k)); for (const k of keys) if (!ordered.includes(k)) ordered.push(k); m.exKeys = ordered; } for (const k of keys) { const rows = byEx[k] || []; if (rows.length) { m.exercises[k] = rows.map(r => ({ id: r.id, weight: r.weight || "", reps: r.reps || "", done: r.done ? 1 : 0, })); } else if (configEx[k]) { // Frische Session: leere Set-Zeilen aus der Config als Vorlage. // Gespeichert wird erst, wenn der Nutzer Werte eingibt. const cfg = configEx[k].sets; const count = cfg.list ? cfg.list.length : (cfg.count || 0); m.exercises[k] = Array.from({ length: count }, () => ({ id: null, weight: "", reps: "", done: 0 })); } else { m.exercises[k] = []; } } return m; } /* --------------------------------------------------------------------------- Heute - Aufbau ------------------------------------------------------------------------- */ async function loadToday() { const res = await api("/api/sessions?status=active"); const byAct = {}; for (const s of res) byAct[s.activity] = s; app.models = {}; for (const a of app.cfg.activities) { if (byAct[a.id]) app.models[a.id] = makeModel(a, byAct[a.id]); } renderToday(); } function renderToday() { const view = $("#view-today"); view.innerHTML = ""; const acts = app.cfg.activities.filter(a => a.active); if (!acts.length) { view.appendChild(el("div", "empty-box", t("no_activities"))); return; } for (const a of acts) { const card = el("section", "act-card"); app.cards[a.id] = card; const title = el("div", "act-title"); title.appendChild(el("h2", "", a.name)); title.appendChild(el("span", "act-kind", a.type === "strength" ? t("kind_strength") : t("kind_cardio"))); card.appendChild(title); renderActivityBody(card, a); view.appendChild(card); } } function renderActivityBody(card, a) { const model = app.models[a.id]; if (model) { if (a.type === "strength") renderStrengthSession(card, a, model); else renderCardioSession(card, a, model); } else { renderStartPicker(card, a); } } function rebuildCard(a) { const card = app.cards[a.id]; if (!card) return; const title = $(".act-title", card); card.innerHTML = ""; card.appendChild(title); renderActivityBody(card, a); } function renderStartPicker(card, a) { const box = el("div", "start-box"); if (a.type === "strength" && a.cycle && a.cycle.days.length) { box.appendChild(el("div", "picker-label", t("pick_day"))); const wrap = el("div", "day-picker"); if (!app.pickedDay[a.id]) app.pickedDay[a.id] = a.cycle.days[0].key; for (const d of a.cycle.days) { const b = el("button", "day-btn" + (d.key === app.pickedDay[a.id] ? " is-active" : ""), d.title); b.type = "button"; b.addEventListener("click", () => { app.pickedDay[a.id] = d.key; for (const other of $$(".day-btn", wrap)) other.classList.remove("is-active"); b.classList.add("is-active"); }); wrap.appendChild(b); } box.appendChild(wrap); } const start = el("button", "btn btn-primary btn-block", t("start_session")); start.type = "button"; start.addEventListener("click", () => startSession(a)); box.appendChild(start); card.appendChild(box); } async function startSession(a) { const body = { activity: a.id }; if (a.type === "strength") body.day = app.pickedDay[a.id]; try { const sess = await api("/api/sessions", jsonOpts("POST", body)); app.models[a.id] = makeModel(a, sess); rebuildCard(a); } catch (e) { flashError(e); } } function renderStrengthSession(card, a, model) { const day = findDay(a, model.day); if (!day) { const nb = el("div", "notice", t("day_missing", { d: model.day })); nb.appendChild(transferBtn(a, model, t("move_today"))); nb.appendChild(discardBtn(a, model)); card.appendChild(nb); return; } card.appendChild(sessionHead(a, model, day.title, day)); if (model.date !== app.meta.today) { const nb = el("div", "notice", t("in_work", { d: fmtDate(model.date) })); nb.appendChild(transferBtn(a, model, t("move_today"))); card.appendChild(nb); } const dayKeys = day.exercises.map(x => x.key); const grid = el("div", "ex-grid"); model.exGrid = grid; card.appendChild(grid); for (const k of model.exKeys) { const ex = model.exByKey[k] || { key: k, name: k, sets: { count: 0 }, target: "", startKg: "", info: "", tags: [], }; grid.appendChild(buildExBlock(a, model, ex, !dayKeys.includes(k))); } } function renderCardioSession(card, a, model) { card.appendChild(sessionHead(a, model, "", null)); if (model.date !== app.meta.today) { const nb = el("div", "notice", t("in_work", { d: fmtDate(model.date) })); nb.appendChild(transferBtn(a, model, t("move_today"))); card.appendChild(nb); } } function transferBtn(a, model, label) { const b = el("button", "btn btn-small", label); b.type = "button"; b.addEventListener("click", async () => { try { await api("/api/sessions/" + model.id, jsonOpts("PATCH", { meta: { date: app.meta.today } })); model.date = app.meta.today; rebuildCard(a); } catch (e) { flashError(e); } }); return b; } function discardBtn(a, model) { const b = el("button", "btn btn-danger-ghost btn-small", t("discard")); confirmify(b, () => discardSession(a, model), t("confirm_discard")); return b; } function sessionHead(a, model, dayTitle, day) { const head = el("div", "session-head"); if (dayTitle) head.appendChild(el("span", "session-day", dayTitle)); head.appendChild(el("span", "session-when", "gestartet " + (model.start_time || "?"))); head.appendChild(saveIndicator(a)); if (a.type === "cardio") { const wrap = el("div", "cardio-fields"); wrap.className = "cardio-fields"; for (const t of a.fieldTemplates || []) { const im = t.kind === "number" ? "decimal" : "text"; wrap.appendChild(makeFieldBox(t.label, t.unit, model.fields[t.key] || "", v => { model.fields[t.key] = v; model.metaDirty = true; scheduleAutosave(model); }, im)); } head.appendChild(wrap); } else if (a.cycle) { head.appendChild(exercisePicker(a, model, day)); } const noteBox = el("label", "note-field"); const noteInput = el("input", "note-input"); noteInput.type = "text"; noteInput.placeholder = t("note_placeholder"); noteInput.value = model.note || ""; noteInput.addEventListener("input", () => { model.note = noteInput.value.trim(); model.metaDirty = true; scheduleAutosave(model); }); noteBox.appendChild(noteInput); head.appendChild(noteBox); const actions = el("div", "session-actions"); if (a.type === "strength") { const resetAll = el("button", "btn btn-small", t("reset_all")); resetAll.type = "button"; resetAll.title = t("reset_all_title"); resetAll.addEventListener("click", () => resetAllSets(a, model)); actions.appendChild(resetAll); } const fin = el("button", "btn btn-primary", t("finish_session")); fin.type = "button"; fin.addEventListener("click", () => finishSession(a, model)); actions.appendChild(fin); head.appendChild(actions); const disc = discardBtn(a, model); disc.classList.add("session-discard"); head.appendChild(disc); return head; } /* Uebung aus einem anderen Tag zur laufenden Session hinzufuegen */ function exercisePicker(a, model, day) { const sel = el("select", "ex-picker"); const none = el("option", "", t("add_exercise")); none.value = ""; sel.appendChild(none); for (const d of a.cycle.days) { const available = d.exercises.filter(ex => !model.exKeys.includes(ex.key)); if (!available.length) continue; const g = el("optgroup"); g.label = d.title; for (const ex of available) { const opt = el("option", "", ex.name); opt.value = ex.key; g.appendChild(opt); } sel.appendChild(g); } sel.addEventListener("change", () => { if (sel.value) addExercise(a, model, sel.value); sel.value = ""; }); return sel; } function addExercise(a, model, exKey) { if (model.exKeys.includes(exKey)) return; const ex = model.exByKey[exKey]; if (!ex) return; model.exKeys.push(exKey); const cfg = ex.sets; const count = cfg.list ? cfg.list.length : (cfg.count || 1); model.exercises[exKey] = Array.from({ length: count }, () => ({ id: null, weight: "", reps: "", done: 0 })); const target = model.exGrid || app.cards[model.activity]; if (target) target.appendChild(buildExBlock(a, model, ex, true)); model.dirtyEx.add(exKey); scheduleAutosave(model); } function removeExercise(a, model, exKey) { const block = model.exBlocks[exKey]; if (block) block.remove(); model.exKeys = model.exKeys.filter(k => k !== exKey); delete model.exercises[exKey]; model.dirtyEx.add(exKey); scheduleAutosave(model); } /* Uebungen per Drag & Drop sortieren (Reihenfolge wird in session.fields gespeichert, damit sie Reloads uebersteht). */ function reorderExercise(a, model, fromKey, toKey) { if (!fromKey || fromKey === toKey) return; const keys = model.exKeys; const from = keys.indexOf(fromKey); const to = keys.indexOf(toKey); if (from < 0 || to < 0) return; keys.splice(from, 1); keys.splice(to, 0, fromKey); const target = model.exGrid || app.cards[model.activity]; if (target) { const blocks = keys.map(k => model.exBlocks[k]).filter(Boolean); for (const b of blocks) target.appendChild(b); } model.fields = Object.assign({}, model.fields, { exerciseOrder: keys.slice() }); model.metaDirty = true; scheduleAutosave(model); } function attachDragReorder(a, model, ex, block, grip) { grip.draggable = true; grip.addEventListener("dragstart", ev => { app.dragKey = ex.key; try { ev.dataTransfer = ev.dataTransfer || {}; ev.dataTransfer.setData("text/plain", ex.key); } catch (_) {} }); grip.addEventListener("dragend", () => { app.dragKey = null; for (const b of $$(".ex-block.drag-over")) b.classList.remove("drag-over"); }); block.addEventListener("dragover", ev => { ev.preventDefault(); block.classList.add("drag-over"); }); block.addEventListener("dragleave", () => block.classList.remove("drag-over")); block.addEventListener("drop", ev => { ev.preventDefault(); block.classList.remove("drag-over"); reorderExercise(a, model, app.dragKey, ex.key); app.dragKey = null; }); } async function resetAllSets(a, model) { try { const sess = await api("/api/sessions/" + model.id + "/autofill", { method: "POST" }); app.models[a.id] = makeModel(a, sess); rebuildCard(a); toast(t("reset_all_done"), "ok"); } catch (e) { flashError(e); } } function saveIndicator(a) { const s = el("span", "save-ind", t("saved")); app.saveInd[a.id] = s; return s; } const SAVE_TXT = { saved: "saved", saving: "saving", error: "error", }; function setSaveState(aid, st) { const s = app.saveInd[aid]; if (!s) return; s.textContent = SAVE_TXT[st] ? t(SAVE_TXT[st]) : ""; s.className = "save-ind is-" + st; } /* --------------------------------------------------------------------------- Uebungs-Block + Saetze ------------------------------------------------------------------------- */ function buildExBlock(a, model, ex, removable) { const block = el("section", "ex-block"); model.exBlocks[ex.key] = block; model.exByKey[ex.key] = ex; const head = el("div", "ex-head"); const grip = el("span", "ex-grip", ""); grip.innerHTML = ''; grip.title = t("reorder_hint"); grip.setAttribute("aria-label", t("reorder_hint")); head.appendChild(grip); const infoBtn = el("button", "icon-btn", "?"); infoBtn.type = "button"; infoBtn.setAttribute("aria-label", t("info_aria") + ex.name); infoBtn.addEventListener("click", () => openInfo(ex)); head.appendChild(infoBtn); head.appendChild(el("span", "ex-name", ex.name)); const hint = exHint(a, ex); if (hint && app.settings && app.settings.showTips !== false) { head.appendChild(el("span", "ex-hint", hint)); } const actions = el("div", "ex-actions"); if (removable) { const rm = el("button", "btn btn-small ex-remove", "Entfernen"); rm.type = "button"; rm.setAttribute("aria-label", t("remove_exercise_aria")); rm.addEventListener("click", () => removeExercise(a, model, ex.key)); actions.appendChild(rm); } const add = el("button", "btn btn-small ex-add", t("add_set")); add.type = "button"; add.addEventListener("click", () => addSet(a, model, ex.key)); actions.appendChild(add); head.appendChild(actions); block.appendChild(head); block.appendChild(el("div", "set-rows")); renderExRows(a, model, ex); attachDragReorder(a, model, ex, block, grip); return block; } function exHint(a, ex) { const parts = []; const s = ex.sets; if (s.list) parts.push(s.list.length + " " + t("set_count")); else if (s.count) parts.push(s.count + " " + t("set_count")); if (ex.target) parts.push(ex.target + " " + a.repsLabel); if (ex.startKg !== undefined && ex.startKg !== "") { parts.push(t("start_hint") + " " + ex.startKg + " " + weightUnitLabel(a)); } return parts.join(" · "); } function renderExRows(a, model, ex) { const block = model.exBlocks[ex.key]; const rows = $(".set-rows", block); rows.innerHTML = ""; const sets = model.exercises[ex.key] || (model.exercises[ex.key] = []); model.exChecks[ex.key] = []; sets.forEach((entry, i) => rows.appendChild(buildSetRow(a, model, ex, entry, i))); } function buildSetRow(a, model, ex, entry, i) { const row = el("div", "set-row" + (entry.done ? " is-done" : "")); const chk = el("button", "set-check" + (entry.done ? " is-done" : ""), ""); chk.type = "button"; chk.setAttribute("aria-pressed", entry.done ? "true" : "false"); chk.setAttribute("aria-label", t("set_done_aria", { n: i + 1 })); chk.appendChild(el("span", "set-check-label", "Set")); chk.appendChild(el("span", "set-check-num", String(i + 1))); chk.appendChild(el("span", "set-check-box", entry.done ? "✓" : "")); chk.addEventListener("click", () => toggleSetDone(a, model, ex.key, i, chk)); row.appendChild(chk); model.exChecks[ex.key].push(chk); const wBox = makeFieldBox(t("weight"), weightUnitLabel(a), entry.weight || "", v => { entry.weight = v; model.dirtyEx.add(ex.key); scheduleAutosave(model); }); const rBox = makeFieldBox(t("reps"), a.repsLabel, entry.reps || "", v => { entry.reps = v; model.dirtyEx.add(ex.key); scheduleAutosave(model); }); row.appendChild(wBox); row.appendChild(rBox); entry.wInput = wBox.querySelector("input"); entry.rInput = rBox.querySelector("input"); row.appendChild(el("span", "set-gap")); const reset = el("button", "icon-btn", ""); reset.type = "button"; reset.setAttribute("aria-label", t("reset_set_title")); reset.appendChild(resetSvg()); reset.addEventListener("click", () => resetSet(a, model, ex.key, i, entry)); row.appendChild(reset); const remove = el("button", "icon-btn", "×"); remove.type = "button"; remove.setAttribute("aria-label", t("remove_set_aria")); remove.addEventListener("click", () => removeSet(a, model, ex.key, i)); row.appendChild(remove); return row; } function resetSvg() { const svg = el("span", ""); svg.innerHTML = ''; return svg; } function makeFieldBox(label, unit, value, onChange, inputmode) { const box = el("label", "field-box"); box.setAttribute("aria-label", label + " in " + unit); const input = el("input", "field-input"); input.type = "text"; input.inputmode = inputmode || "decimal"; input.autocomplete = "off"; input.spellcheck = false; input.value = value || ""; input.addEventListener("input", () => onChange(input.value.trim())); box.appendChild(input); if (unit) box.appendChild(el("span", "field-unit", unit)); return box; } /* Set-Kette: Set i aktivieren schaltet 1..i ein, deaktivieren rueckwaerts. Gewicht/Reps werden durch den Klick nie veraendert. */ function toggleSetDone(a, model, exKey, i, chk) { const sets = model.exercises[exKey]; if (!sets[i].done) { for (let k = 0; k <= i; k++) sets[k].done = 1; } else { for (let k = i; k < sets.length; k++) sets[k].done = 0; } const checks = model.exChecks[exKey]; for (let k = 0; k < checks.length; k++) { const on = !!sets[k].done; checks[k].classList.toggle("is-done", on); checks[k].setAttribute("aria-pressed", on ? "true" : "false"); const box = $(".set-check-box", checks[k]); if (box) box.textContent = on ? "✓" : ""; checks[k].parentElement.classList.toggle("is-done", on); } model.dirtyEx.add(exKey); scheduleAutosave(model); } function addSet(a, model, exKey) { model.exercises[exKey].push({ id: null, weight: "", reps: "", done: 0 }); renderExRows(a, model, model.exByKey[exKey]); model.dirtyEx.add(exKey); scheduleAutosave(model); } function removeSet(a, model, exKey, i) { const sets = model.exercises[exKey]; sets.splice(i, 1); renderExRows(a, model, model.exByKey[exKey]); model.dirtyEx.add(exKey); scheduleAutosave(model); } async function resetSet(a, model, exKey, i, entry) { const url = "/api/reset-values?exercise=" + encodeURIComponent(exKey) + "&set=" + i; try { const r = await api(url); if (!r.weight) { toast(t("no_prev_measure", { n: i + 1 })); return; } entry.weight = r.weight; entry.reps = r.reps || ""; entry.wInput.value = entry.weight; entry.rInput.value = entry.reps; model.dirtyEx.add(exKey); scheduleAutosave(model); } catch (e) { flashError(e); } } /* --------------------------------------------------------------------------- Autosave: nur schmutzige Uebungen / Metadaten gehen raus. Das DOM wird dabei nie angefasst - keine Fokus-Verluste. ------------------------------------------------------------------------- */ function scheduleAutosave(model) { clearTimeout(app.autosaveTimer[model.activity]); app.autosaveTimer[model.activity] = setTimeout( () => flushAutosave(model.activity), 700); setSaveState(model.activity, "saving"); } async function flushAutosave(activity) { if (app.flushBusy) { app.flushQueuedActivity = activity; return; } app.flushBusy = true; try { const model = app.models[activity]; if (!model) return; let ok = true; if (model.metaDirty) { const meta = { note: model.note, fields: model.fields }; try { await api("/api/sessions/" + model.id, jsonOpts("PATCH", { meta })); model.metaDirty = false; } catch (_) { ok = false; } } for (const exKey of Array.from(model.dirtyEx)) { // Entfernte Uebungen werden mit leerem Array uebertragen -> Server // loescht ihre Eintraege. Andere Uebungen bleiben unberuehrt. const sets = (model.exercises[exKey] || []).map((e, i) => ({ exercise: exKey, sort: i, weight: e.weight || "", reps: e.reps || "", done: e.done ? 1 : 0, })).filter(s => s.weight !== "" || s.reps !== ""); try { await api("/api/sessions/" + model.id, jsonOpts("PATCH", { sets })); model.dirtyEx.delete(exKey); } catch (_) { ok = false; } } setSaveState(activity, ok ? "saved" : "error"); } finally { app.flushBusy = false; if (app.flushQueuedActivity) { const queued = app.flushQueuedActivity; app.flushQueuedActivity = null; flushAutosave(queued); } } } async function finishSession(a, model) { setSaveState(a.id, "saving"); try { await flushAutosave(a.id); if (model.dirtyEx.size || model.metaDirty) { toast(t("save_failed"), "error"); setSaveState(a.id, "error"); return; } await api("/api/sessions/" + model.id, jsonOpts("PATCH", { meta: { status: "done", end_time: app.meta.now } })); } catch (e) { flashError(e); setSaveState(a.id, "error"); return; } delete app.models[a.id]; rebuildCard(a); toast(t("session_saved"), "ok"); } async function discardSession(a, model) { try { await api("/api/sessions/" + model.id, { method: "DELETE" }); } catch (e) { flashError(e); return; } delete app.models[a.id]; rebuildCard(a); toast(t("session_discarded")); } /* --------------------------------------------------------------------------- Bestaetigungs-Knopf (2-Schritt: klicken, dann kurz t("sure")) ------------------------------------------------------------------------- */ function confirmify(btn, onConfirm, msg) { btn.addEventListener("click", () => { if (btn.dataset.armed === "1") { btn.dataset.armed = "0"; onConfirm(); return; } btn.dataset.armed = "1"; const old = btn.textContent; btn.textContent = msg; btn.classList.add("is-armed"); setTimeout(() => { if (btn.dataset.armed === "1") { btn.dataset.armed = "0"; btn.textContent = old; btn.classList.remove("is-armed"); } }, 3000); }); } /* --------------------------------------------------------------------------- Verlauf ------------------------------------------------------------------------- */ let histMode = "list"; let weekStart = null; async function loadHistory() { const res = await api("/api/sessions?status=done"); renderHistory(res); } function renderHistory(sessions) { const view = $("#view-history"); view.innerHTML = ""; if (!sessions.length && histMode === "list") { view.appendChild(el("div", "empty-box", t("no_history"))); return; } const nav = el("div", "day-picker"); for (const [mode, label] of [["list", t("list_view")], ["week", t("week")]]) { const b = el("button", "day-btn" + (histMode === mode ? " is-active" : ""), label); b.type = "button"; b.addEventListener("click", () => { histMode = mode; renderHistory(sessions); }); nav.appendChild(b); } view.appendChild(nav); if (histMode === "week") { renderWeekView(view, sessions); } else { const byDate = {}; for (const s of sessions) (byDate[s.date] = byDate[s.date] || []).push(s); const dates = Object.keys(byDate).sort().reverse(); for (const d of dates) { const group = el("section", "hist-group"); group.appendChild(el("h3", "hist-date", fmtDateLong(d))); for (const s of byDate[d]) group.appendChild(buildHistCard(s)); view.appendChild(group); } if (!sessions.length) view.appendChild(el("div", "empty-box", t("no_history"))); } } function mondayOf(iso) { const p = iso.split("-"); const dt = new Date(+p[0], +p[1] - 1, +p[2]); const dow = (dt.getDay() + 6) % 7; dt.setDate(dt.getDate() - dow); return isoFromDate(dt); } function isoFromDate(dt) { return dt.getFullYear() + "-" + String(dt.getMonth() + 1).padStart(2, "0") + "-" + String(dt.getDate()).padStart(2, "0"); } function addDaysIso(iso, n) { const p = iso.split("-"); return isoFromDate(new Date(+p[0], +p[1] - 1, +p[2] + n)); } function renderWeekView(view, sessions) { if (!weekStart) weekStart = mondayOf(app.meta.today); const byDate = {}; for (const s of sessions) (byDate[s.date] = byDate[s.date] || []).push(s); const picker = el("div", "data-row"); const prev = el("button", "btn btn-small", t("prev_week")); prev.type = "button"; prev.addEventListener("click", () => { weekStart = addDaysIso(weekStart, -7); renderHistory(sessions); }); picker.appendChild(prev); picker.appendChild(el("span", "muted", t("week_of") + " " + fmtDate(weekStart))); const next = el("button", "btn btn-small", t("next_week")); next.type = "button"; next.addEventListener("click", () => { weekStart = addDaysIso(weekStart, 7); renderHistory(sessions); }); picker.appendChild(next); view.appendChild(picker); const grid = el("div", "week-grid"); for (let i = 0; i < 7; i++) { const d = addDaysIso(weekStart, i); const cell = el("div", "week-day"); const head = el("div", "week-day-head", WD[i] + " " + fmtDate(d)); if (d === app.meta.today) head.classList.add("is-today"); cell.appendChild(head); const daySessions = byDate[d] || []; for (const s of daySessions) { const a = cfgActivity(s.activity); const item = el("div", "week-session"); const title = (a ? a.name : s.activity) + (s.day ? " · " + s.day : ""); item.appendChild(el("span", "week-session-title", title)); item.appendChild(el("span", "muted", timeRange(s))); cell.appendChild(item); } if (!daySessions.length) { cell.appendChild(el("div", "muted", t("no_sessions_week"))); } grid.appendChild(cell); } view.appendChild(grid); } function buildHistCard(s) { const card = el("article", "hist-card"); const a = cfgActivity(s.activity); const head = el("div", "hist-head"); head.appendChild(el("span", "hist-when", timeRange(s))); const label = s.day ? (a ? a.name : s.activity) + " · " + s.day : (a ? a.name : s.activity); head.appendChild(el("span", "hist-activity", label)); const actions = el("div", "actions"); const edit = el("button", "btn btn-small", t("edit")); edit.type = "button"; edit.addEventListener("click", () => buildHistEdit(card, s)); actions.appendChild(edit); const del = el("button", "btn btn-danger-ghost btn-small", t("delete")); confirmify(del, async () => { try { await api("/api/sessions/" + s.id, { method: "DELETE" }); loadHistory(); toast(t("session_deleted")); } catch (e) { flashError(e); } }, t("sure")); actions.appendChild(del); head.appendChild(actions); card.appendChild(head); if (s.note) card.appendChild(el("div", "hist-note", t("note_prefix") + s.note)); if (a && a.type === "cardio") { card.appendChild(renderCardioSummary(s, a)); } else { card.appendChild(renderSetSummary(s, a)); } return card; } function timeRange(s) { if (s.start_time && s.end_time) return s.start_time + t("to_time") + s.end_time; if (s.start_time) return t("from_time") + s.start_time; return t("time_unknown"); } function renderSetSummary(s, a) { const ul = el("ul", "hist-entries"); const unit = weightUnitLabel(a); const repsUnit = a ? a.repsLabel : "reps"; const byEx = {}; for (const e of s.entries || []) (byEx[e.exercise] = byEx[e.exercise] || []).push(e); for (const exKey of Object.keys(byEx)) { const li = el("li", "hist-entry"); const sorted = byEx[exKey].sort((x, y) => x.sort - y.sort) .filter(e => e.weight || e.reps); if (!sorted.length) continue; li.appendChild(el("span", "ex-name", exKey + ": ")); const setsWrap = el("span", "hist-sets"); sorted.forEach((e, idx) => { const w = e.weight || t("empty_label"); const r = e.reps || t("empty_label"); const span = el("span", "hist-set"); span.appendChild(el("span", e.done ? "set-done-box" : "set-undone-box", e.done ? "✓" : "□")); span.appendChild(document.createTextNode( w + " " + unit + " × " + r + " " + repsUnit)); setsWrap.appendChild(span); if (idx < sorted.length - 1) { setsWrap.appendChild(document.createTextNode(" · ")); } }); li.appendChild(setsWrap); ul.appendChild(li); } return ul; } function renderCardioSummary(s, a) { const ul = el("ul", "hist-entries"); const li = el("li", "hist-entry"); const parts = []; for (const t of a.fieldTemplates || []) { const v = (s.fields || {})[t.key]; if (v !== undefined && v !== null && String(v).trim() !== "") { parts.push(t.label + " " + v + " " + t.unit); } } for (const k of Object.keys(s.fields || {})) { const known = (a.fieldTemplates || []).some(t => t.key === k); if (!known && String(s.fields[k]).trim() !== "") parts.push(k + " " + s.fields[k]); } li.appendChild(el("span", "hist-sets", parts.join(" · ") || t("no_values"))); ul.appendChild(li); return ul; } /* Inline-Editor: bleibt komplett im Verlauf, blutet nicht in "Heute" ab. */ function buildHistEdit(card, s) { const a = cfgActivity(s.activity); const data = { date: s.date, start_time: s.start_time || "", end_time: s.end_time || "", note: s.note || "", fields: Object.assign({}, s.fields || {}), sets: (s.entries || []).map(e => ({ exercise: e.exercise, sort: e.sort, weight: e.weight || "", reps: e.reps || "", done: e.done ? 1 : 0, })), }; const form = el("form", "hist-edit"); form.addEventListener("submit", ev => { ev.preventDefault(); saveHistEdit(a, s, data, card); }); const grid = el("div", "hist-edit-grid"); grid.appendChild(editField(t("date"), "date", data, "date")); grid.appendChild(editField(t("start"), "time", data, "start_time")); grid.appendChild(editField(t("end"), "time", data, "end_time")); const noteLabel = el("label", "", t("note_label")); const noteInput = el("input", ""); noteInput.type = "text"; noteInput.value = data.note; noteInput.addEventListener("input", () => { data.note = noteInput.value.trim(); }); noteLabel.appendChild(noteInput); grid.appendChild(noteLabel); form.appendChild(grid); if (a && a.type === "cardio") { const wrap = el("div", "edit-rows"); for (const t of a.fieldTemplates || []) { const im = t.kind === "number" ? "decimal" : "text"; wrap.appendChild(makeFieldBox(t.label, t.unit, data.fields[t.key] || "", v => { data.fields[t.key] = v; }, im)); } form.appendChild(wrap); } else { const byEx = {}; for (const set of data.sets) (byEx[set.exercise] = byEx[set.exercise] || []).push(set); for (const exKey of Object.keys(byEx)) { const box = el("div", "edit-ex"); box.appendChild(el("div", "ex-name", exKey)); const inner = el("div", "edit-rows"); for (const set of byEx[exKey]) { const row = el("div", "edit-row"); const doneBox = el("input", "edit-done"); doneBox.type = "checkbox"; doneBox.checked = !!set.done; doneBox.setAttribute("aria-label", t("set_done_aria", { n: set.sort + 1 })); doneBox.addEventListener("change", () => { set.done = doneBox.checked ? 1 : 0; }); row.appendChild(doneBox); row.appendChild(el("span", "muted", t("set_n", { n: set.sort + 1 }))); row.appendChild(makeFieldBox(t("weight"), weightUnitLabel(a), set.weight, v => { set.weight = v; })); row.appendChild(makeFieldBox(t("reps"), a ? a.repsLabel : "reps", set.reps, v => { set.reps = v; })); inner.appendChild(row); } box.appendChild(inner); form.appendChild(box); } } const actions = el("div", "edit-actions"); const save = el("button", "btn btn-primary", t("save")); save.type = "submit"; actions.appendChild(save); const cancel = el("button", "btn", t("cancel")); cancel.type = "button"; cancel.addEventListener("click", () => { card.innerHTML = ""; card.appendChild(buildHistCard(s)); }); actions.appendChild(cancel); form.appendChild(actions); card.innerHTML = ""; card.appendChild(form); } function editField(label, type, data, key) { const l = el("label", "", label); const input = el("input", ""); input.type = type; input.value = data[key] || ""; input.addEventListener("input", () => { data[key] = input.value; }); l.appendChild(input); return l; } async function saveHistEdit(a, s, data, card) { const meta = { date: data.date, start_time: data.start_time, end_time: data.end_time, note: data.note, }; if (a && a.type === "cardio") meta.fields = data.fields; try { await api("/api/sessions/" + s.id, jsonOpts("PATCH", { meta, sets: data.sets })); } catch (e) { flashError(e); return; } toast(t("session_updated"), "ok"); loadHistory(); } /* --------------------------------------------------------------------------- Statistik ------------------------------------------------------------------------- */ async function loadStats() { const view = $("#view-stats"); view.innerHTML = ""; const optgroups = []; for (const a of app.cfg.activities) { if (a.type !== "strength" || !a.cycle) continue; for (const d of a.cycle.days) { const g = el("optgroup"); g.label = a.name + " · " + d.title; for (const ex of d.exercises) { const opt = el("option", "", ex.name); opt.value = ex.key; g.appendChild(opt); } optgroups.push(g); } } if (!optgroups.length) { view.appendChild(el("div", "empty-box", t("no_strength"))); return; } const pick = el("div", "stats-pick"); pick.appendChild(el("span", "field-label", t("choose_exercise"))); const sel = el("select", "stats-select"); for (const g of optgroups) sel.appendChild(g); sel.addEventListener("change", () => renderStats(view, sel.value)); pick.appendChild(sel); view.appendChild(pick); sel.value = optgroups[0].children[0].value; renderStats(view, sel.value); } async function renderStats(view, exKey) { const old = $(".stats-table-box", view); if (old) old.remove(); const box = el("div", "stats-table-box"); view.appendChild(box); let data; try { data = await api("/api/stats?exercise=" + encodeURIComponent(exKey)); } catch (e) { box.appendChild(el("div", "empty-box", e.message)); return; } if (!data.length) { box.appendChild(el("div", "empty-box", t("no_stats"))); return; } const maxW = Math.max(...data.map(d => d.weight)); const table = el("table", "stats-table"); const thead = el("thead"); const hr = el("tr"); for (const col of [["date", t("col_date")], ["best", t("col_best")], ["reps", t("col_reps")], ["diff", t("col_diff")]]) { const th = el("th", (col[0] === "best" || col[0] === "diff") ? "num" : "", col[1]); hr.appendChild(th); } thead.appendChild(hr); table.appendChild(thead); const tbody = el("tbody"); for (const d of data) { const tr = el("tr"); tr.appendChild(el("td", "", fmtDate(d.date))); const cell = el("div", "stats-bar-cell"); const bar = el("span", "stats-bar"); bar.style.width = Math.round((d.weight / maxW) * 100) + "%"; cell.appendChild(bar); cell.appendChild(el("span", "", fmtKg(d.weight) + " kg")); const wTd = el("td", "num"); wTd.appendChild(cell); tr.appendChild(wTd); tr.appendChild(el("td", "num", d.reps || "-")); const delta = d.delta; const sign = delta > 0 ? "+" : ""; const cls = delta > 0 ? "delta-pos" : (delta < 0 ? "delta-neg" : "delta-zero"); tr.appendChild(el("td", "num " + cls, sign + fmtKg(delta) + " kg")); tbody.appendChild(tr); } table.appendChild(tbody); box.appendChild(table); } /* --------------------------------------------------------------------------- Daten: Export / Import ------------------------------------------------------------------------- */ function renderDataView() { const view = $("#view-data"); view.innerHTML = ""; const exp = el("section", "data-section"); exp.appendChild(el("h3", "", t("export"))); const eb = el("div", "data-body"); eb.appendChild(el("p", "", "Der komplette Stand (auch in Arbeit befindliche Sessions) als JSON oder CSV. CSV-Spalten: session_id, activity, day, date, start_time, end_time, note, status, kind, exercise, set, weight, reps, done sowie die Cardio-Felder aus der config.")); const row1 = el("div", "data-row"); const a1 = el("a", "btn", t("export_json")); a1.href = "/api/export?format=json"; a1.setAttribute("download", ""); row1.appendChild(a1); const a2 = el("a", "btn", t("export_csv")); a2.href = "/api/export?format=csv"; a2.setAttribute("download", ""); row1.appendChild(a2); eb.appendChild(row1); exp.appendChild(eb); view.appendChild(exp); const imp = el("section", "data-section"); imp.appendChild(el("h3", "", t("import"))); const ib = el("div", "data-body"); ib.appendChild(el("p", "", t("import_desc"))); const row2 = el("div", "data-row"); const fjson = el("input", "file-input"); fjson.type = "file"; fjson.accept = ".json,application/json"; row2.appendChild(fjson); const bjson = el("button", "btn", t("import_json")); bjson.type = "button"; const resJson = el("div", "import-result"); bjson.addEventListener("click", () => doImport(fjson, "trainingsplan-export-v2", resJson)); row2.appendChild(bjson); ib.appendChild(row2); ib.appendChild(resJson); const row3 = el("div", "data-row"); const fcsv = el("input", "file-input"); fcsv.type = "file"; fcsv.accept = ".csv,text/csv"; row3.appendChild(fcsv); const bcsv = el("button", "btn", t("import_csv")); bcsv.type = "button"; const resCsv = el("div", "import-result"); bcsv.addEventListener("click", () => doImport(fcsv, "csv", resCsv)); row3.appendChild(bcsv); ib.appendChild(row3); ib.appendChild(resCsv); imp.appendChild(ib); view.appendChild(imp); } async function doImport(input, format, res) { const file = input.files && input.files[0]; if (!file) { res.className = "import-result is-error"; res.textContent = t("file_first"); return; } try { const text = await file.text(); const r = await api("/api/import", jsonOpts("POST", { format, data: text })); res.className = "import-result"; res.textContent = t("import_ok", { c: r.created, u: r.updated }); } catch (e) { res.className = "import-result is-error"; res.textContent = t("import_fail", { m: e.message }); } } /* --------------------------------------------------------------------------- Overlay (Uebungs-Erklaerung), Tabs, Start ------------------------------------------------------------------------- */ function openInfo(ex) { const root = $("#overlay-root"); root.innerHTML = ""; root.hidden = false; const box = el("div", "overlay-box"); box.appendChild(el("h3", "", ex.name)); if (ex.info) box.appendChild(el("p", "overlay-info", ex.info)); for (const tag of ex.tags || []) box.appendChild(el("span", "tag-chip", tag)); const close = el("button", "btn btn-primary btn-block", t("close")); close.type = "button"; close.addEventListener("click", closeOverlay); box.appendChild(close); root.appendChild(box); } function closeOverlay() { const root = $("#overlay-root"); root.hidden = true; root.innerHTML = ""; } function setupOverlay() { $("#overlay-root").addEventListener("click", ev => { if (ev.target.id === "overlay-root") closeOverlay(); }); } function switchView(name) { for (const t of $$(".tab")) { t.classList.toggle("is-active", t.dataset.view === name); } for (const v of $$(".view")) { v.classList.toggle("is-active", v.id === "view-" + name); } if (name === "history") loadHistory(); if (name === "stats") loadStats(); if (name === "data") renderDataView(); if (name === "settings") renderSettingsView(); } /* --------------------------------------------------------------------------- Setup-Wizard + Anmeldung ------------------------------------------------------------------------- */ const wizard = { mode: "setup", step: 0, multi: null, lang: null, theme: null, oled: false, accent: null, regUser: null, regPass: null }; function renderSetupWizard(mode) { wizard.mode = mode; wizard.step = 0; // Reihenfolge: Sprache -> Darstellung -> Modus wizard.multi = null; wizard.lang = (app.settings && app.settings.language) || "de"; wizard.theme = (app.settings && app.settings.theme) || "dark"; wizard.oled = !!(app.settings && app.settings.oled); wizard.accent = (app.settings && app.settings.accent) || "white"; wizard.regUser = null; wizard.regPass = null; document.body.classList.add("no-auth"); $("#setup-gate").hidden = false; renderWizardStep(); } function setupOption(title, desc, active) { const b = el("button", "setup-option" + (active ? " is-active" : ""), ""); b.type = "button"; b.appendChild(el("strong", "", title)); b.appendChild(el("span", "setup-option-desc", desc)); return b; } function wizardToggle(key, label, desc, value, onChange) { const row = el("label", "toggle-row"); const cb = el("input", "toggle-input"); cb.type = "checkbox"; cb.checked = !!value; cb.addEventListener("change", () => { wizard[key] = cb.checked; if (onChange) onChange(); }); const txt = el("span", "toggle-text"); txt.appendChild(el("strong", "", label)); if (desc) txt.appendChild(el("span", "muted", desc)); row.appendChild(cb); row.appendChild(txt); return row; } function previewWizardAppearance() { applySettings(Object.assign({}, app.settings, { theme: wizard.theme, oled: wizard.oled, accent: wizard.accent, })); } function wizardStepValid() { if (wizard.step === 0) return !!wizard.lang; if (wizard.step === 1) return !!wizard.theme && !!wizard.accent; // Schritt 2 (nur Setup): Modus if (wizard.multi === null) return false; if (wizard.multi === false) return true; return !!(wizard.regUser && wizard.regPass); } function wizardMaxStep() { return wizard.mode === "setup" ? 2 : 1; } function wizardProgress() { const count = wizard.mode === "setup" ? 3 : 2; const wrap = el("div", "wizard-progress"); for (let i = 0; i < count; i++) { const cls = i < wizard.step ? " is-done" : i === wizard.step ? " is-current" : ""; wrap.appendChild(el("span", "wp-step" + cls, "")); } return wrap; } function renderWizardStep() { const gate = $("#setup-gate"); gate.innerHTML = ""; const box = el("div", "auth-box"); const head = el("div", "auth-head"); head.appendChild(el("h2", "auth-title", BRAND)); head.appendChild(wizardProgress()); box.appendChild(head); const body = el("div", "wizard-body"); if (wizard.step === 0) { // Schritt 0: Sprache body.appendChild(el("h3", "wizard-title", t("lang_step"))); if (wizard.mode === "setup") { body.appendChild(el("p", "auth-sub", t("lang_server"))); } const row = el("div", "day-picker"); for (const [code, label] of [["de", t("german")], ["en", t("english")]]) { const b = el("button", "day-btn" + (wizard.lang === code ? " is-active" : ""), label); b.type = "button"; b.addEventListener("click", () => { wizard.lang = code; app.settings = Object.assign({}, app.settings, { language: code }); renderWizardStep(); }); row.appendChild(b); } body.appendChild(row); if (wizard.mode === "setup") { body.appendChild(el("p", "auth-sub", t("wizard_lang_hint"))); const repo = el("a", "btn", "git.somantis.net/J4stMarkus/trainingsplan"); repo.href = "https://git.somantis.net/J4stMarkus/trainingsplan"; repo.target = "_blank"; repo.rel = "noopener"; body.appendChild(repo); } } else if (wizard.step === 1) { // Schritt 1: Darstellung (Theme + OLED + Akzent) body.appendChild(el("h3", "wizard-title", t("appearance"))); body.appendChild(el("p", "auth-sub", t("appearance_desc"))); body.appendChild(el("div", "picker-label", t("theme_label"))); const themeRow = el("div", "day-picker"); for (const [key, label] of [["dark", t("dark")], ["system", t("system")], ["light", t("light")]]) { const b = el("button", "day-btn" + (wizard.theme === key ? " is-active" : ""), label); b.type = "button"; b.addEventListener("click", () => { wizard.theme = key; previewWizardAppearance(); renderWizardStep(); }); themeRow.appendChild(b); } body.appendChild(themeRow); body.appendChild(wizardToggle("oled", t("oled_toggle"), t("oled_desc"), wizard.oled, previewWizardAppearance)); body.appendChild(el("div", "picker-label", t("accent_label"))); const accentRow = el("div", "accent-row"); for (const [key, color] of Object.entries(ACCENTS)) { const sw = el("button", "accent-swatch" + (wizard.accent === key ? " is-active" : ""), ""); sw.type = "button"; sw.style.background = color; sw.setAttribute("aria-label", t("accent_aria", { k: key })); sw.addEventListener("click", () => { wizard.accent = key; previewWizardAppearance(); renderWizardStep(); }); accentRow.appendChild(sw); } body.appendChild(accentRow); } else { // Schritt 2 (nur Setup): Einzel- oder Mehrbenutzermodus body.appendChild(el("p", "auth-sub", t("welcome"))); const grid = el("div", "setup-options"); const multi = setupOption(t("multi_mode"), t("multi_desc"), wizard.multi === true); multi.addEventListener("click", () => { wizard.multi = true; renderWizardStep(); }); grid.appendChild(multi); const solo = setupOption(t("solo_mode"), t("solo_desc"), wizard.multi === false); solo.addEventListener("click", () => { wizard.multi = false; renderWizardStep(); }); grid.appendChild(solo); body.appendChild(grid); if (wizard.multi === true) { const form = el("form", "auth-form"); form.appendChild(authField(t("username"), "setup-user")); form.appendChild(authField(t("password"), "setup-pass")); form.appendChild(el("div", "auth-register", t("wizard_user_later"))); form.appendChild(el("div", "auth-register", t("wizard_admin_hint"))); form.addEventListener("submit", ev => ev.preventDefault()); body.appendChild(form); // Felder in wizard-Zustand schreiben; Finish live aktivieren const uInput = form.querySelector("#setup-user"); const pInput = form.querySelector("#setup-pass"); uInput.value = wizard.regUser || ""; pInput.value = wizard.regPass || ""; const sync = () => { wizard.regUser = uInput.value.trim(); wizard.regPass = pInput.value.trim(); const fin = box.querySelector(".wizard-nav .btn-primary"); if (fin) fin.disabled = !wizardStepValid(); }; uInput.addEventListener("input", sync); pInput.addEventListener("input", sync); } } box.appendChild(body); // Fusszeile: Zurueck links, Weiter/Abschliessen rechts const nav = el("div", "wizard-nav"); if (wizard.step > 0) { const back = el("button", "btn", t("back")); back.type = "button"; back.addEventListener("click", () => { wizard.step--; renderWizardStep(); }); nav.appendChild(back); } const right = el("div", "wizard-nav-right"); if (wizard.step < wizardMaxStep()) { const next = el("button", "btn btn-primary", t("next")); next.type = "button"; next.disabled = !wizardStepValid(); next.addEventListener("click", () => { wizard.step++; renderWizardStep(); }); right.appendChild(next); } else { const finish = el("button", "btn btn-primary", t("finish_setup")); finish.type = "button"; finish.disabled = !wizardStepValid(); finish.addEventListener("click", wizardFinish); right.appendChild(finish); } nav.appendChild(right); box.appendChild(nav); gate.appendChild(box); } async function wizardFinish() { const patch = { theme: wizard.theme, oled: wizard.oled, accent: wizard.accent, language: wizard.lang, }; if (wizard.mode === "setup") { patch.setupDone = true; if (wizard.multi) { try { const r = await api("/api/register", jsonOpts("POST", { username: wizard.regUser, password: wizard.regPass, })); localStorage.setItem("tp_token", r.token); app.me = { username: r.username, is_owner: true }; } catch (e) { if (e.status === 403) { setTimeout(() => afterAuth(), 600); } else { flashError(e); } return; } } } if (app.me && app.me.username) patch.onboarded = true; try { app.settings = await api("/api/settings", jsonOpts("PUT", patch)); } catch (e) { flashError(e); return; } await afterAuth(); } /* --------------------------------------------------------------------------- Anmeldung (Auth-Gate) ------------------------------------------------------------------------- */ function renderAuth() { document.body.classList.add("no-auth"); const gate = $("#auth-gate"); gate.hidden = false; gate.innerHTML = ""; const box = el("div", "auth-box"); box.appendChild(el("h2", "auth-title", BRAND)); box.appendChild(el("p", "auth-sub", t("login_sub"))); const form = el("form", "auth-form"); form.appendChild(authField(t("username"), "auth-user")); form.appendChild(authField(t("password"), "auth-pass")); const err = el("div", "auth-error", ""); err.hidden = true; const submit = el("button", "btn btn-primary btn-block", t("login")); submit.type = "submit"; form.appendChild(submit); form.appendChild(err); form.addEventListener("submit", ev => { ev.preventDefault(); doAuth("login", $("#auth-user").value, $("#auth-pass").value, err); }); box.appendChild(form); gate.appendChild(box); } function authField(label, id) { const l = el("label", "auth-field", label); const input = el("input", ""); input.id = id; input.type = id.includes("pass") ? "password" : "text"; input.autocomplete = id.includes("pass") ? "current-password" : "username"; l.appendChild(input); return l; } async function doAuth(mode, username, password, errEl) { try { const r = await api("/api/" + mode, jsonOpts("POST", { username, password, })); localStorage.setItem("tp_token", r.token); toast(mode === "register" ? t("user_created_toast") : t("logged_in"), "ok"); await afterAuth(); } catch (e) { if (errEl) { errEl.hidden = false; errEl.textContent = eMsg(e); } else { flashError(e); } } } function logout() { api("/api/logout", { method: "POST" }).catch(() => {}); localStorage.removeItem("tp_token"); app.me = null; app.models = {}; $("#view-today").innerHTML = ""; $("#today-label").textContent = ""; api("/api/meta").then(meta => { app.meta = meta; app.settings = { language: app.meta ? app.meta.language : "de" }; document.body.classList.add("no-auth"); $("#setup-gate").hidden = true; $("#setup-gate").innerHTML = ""; renderAuth(); }); } function logoutButton() { const b = el("button", "btn btn-danger-ghost", t("logout_label")); b.type = "button"; b.addEventListener("click", () => logout()); return b; } /* --------------------------------------------------------------------------- Einstellungen ------------------------------------------------------------------------- */ function prefersDark() { try { return !!(window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches); } catch (_) { return false; } } function applySettings(s) { const root = document.documentElement; let t = s.theme || "dark"; if (t === "system") t = prefersDark() ? "dark" : "light"; if (s.oled && t === "dark") t = "oled"; root.dataset.theme = t; root.dataset.accent = s.accent || "white"; $("#view-today").classList.toggle("no-tips", s.showTips === false); } function setupThemeWatch() { try { if (!window.matchMedia) return; const mq = window.matchMedia("(prefers-color-scheme: dark)"); if (mq.addEventListener) { mq.addEventListener("change", () => { if (app.settings) applySettings(app.settings); }); } } catch (_) {} } function weightUnitLabel(a) { const s = app.settings && app.settings.weightUnit; return (s && (s === "lb")) ? "lb" : (a && a.weightUnit) || "kg"; } async function setSetting(key, value) { app.settings[key] = value; try { app.settings = await api("/api/settings", jsonOpts("PUT", { [key]: value })); } catch (e) { flashError(e); return; } applySettings(app.settings); renderSettingsView(); toast(t("setting_saved"), "accent"); } function renderSettingsView() { const view = $("#view-settings"); view.innerHTML = ""; const sec1 = el("section", "data-section"); sec1.appendChild(el("h3", "", t("appearance"))); const b1 = el("div", "data-body"); b1.appendChild(el("div", "picker-label", t("language_label"))); const langRow = el("div", "day-picker"); for (const [code, label] of [["de", t("german")], ["en", t("english")]]) { const b = el("button", "day-btn" + (app.settings.language === code ? " is-active" : ""), label); b.type = "button"; b.addEventListener("click", () => setSetting("language", code)); langRow.appendChild(b); } b1.appendChild(langRow); b1.appendChild(el("div", "picker-label", t("theme_label"))); const themeRow = el("div", "day-picker"); for (const [key, label] of [["dark", t("dark")], ["system", t("system")], ["light", t("light")]]) { const b = el("button", "day-btn" + (app.settings.theme === key ? " is-active" : ""), label); b.type = "button"; b.addEventListener("click", () => setSetting("theme", key)); themeRow.appendChild(b); } b1.appendChild(themeRow); b1.appendChild(toggleRow("oled", t("oled_toggle"), t("oled_desc"))); b1.appendChild(el("div", "picker-label", t("accent_label"))); const accentRow = el("div", "accent-row"); for (const [key, color] of Object.entries(ACCENTS)) { const sw = el("button", "accent-swatch" + (app.settings.accent === key ? " is-active" : ""), ""); sw.type = "button"; sw.style.background = color; sw.setAttribute("aria-label", t("accent_aria", { k: key })); sw.addEventListener("click", () => setSetting("accent", key)); accentRow.appendChild(sw); } b1.appendChild(accentRow); sec1.appendChild(b1); view.appendChild(sec1); const sec2 = el("section", "data-section"); sec2.appendChild(el("h3", "", t("behavior"))); const b2 = el("div", "data-body"); b2.appendChild(toggleRow("autoReset", t("last_workout"), t("last_workout_desc"))); b2.appendChild(toggleRow("planPrefill", t("plan_prefill"), t("plan_prefill_desc"))); b2.appendChild(toggleRow("showTips", t("show_tips"), t("show_tips_desc"))); b2.appendChild(settingsSelect("weightUnit", t("weight_unit"), [["kg", "kg"], ["lb", "lb"]])); b2.appendChild(settingsSelect("startTab", t("start_tab"), [["today", t("tab_today")], ["history", t("tab_history")], ["stats", t("tab_stats")], ["data", t("tab_data")]])); const resetDef = el("button", "btn btn-small", t("reset_defaults")); resetDef.type = "button"; resetDef.addEventListener("click", async () => { app.settings = await api("/api/settings", jsonOpts("PUT", { theme: "dark", oled: false, accent: "white", autoReset: false, planPrefill: false, showTips: true, language: "de", weightUnit: "kg", startTab: "today" })); applySettings(app.settings); renderSettingsView(); toast(t("defaults_reset"), "accent"); }); const resetRow = el("div", "data-row"); resetRow.appendChild(resetDef); b2.appendChild(resetRow); sec2.appendChild(b2); view.appendChild(sec2); if (app.me && app.me.username) { view.appendChild(renderAccountSection()); } if (app.me && (!app.me.username || app.me.is_owner)) { view.appendChild(renderUsersSection()); } if (app.me && (!app.me.username || app.me.is_owner)) { view.appendChild(renderResetSection()); } } function settingsSelect(key, label, options) { const wrap = el("label", "settings-select-row", label); const sel = el("select", "stats-select"); for (const [value, text] of options) { const opt = el("option", "", text); opt.value = value; sel.appendChild(opt); } sel.value = app.settings[key] || options[0][0]; sel.addEventListener("change", () => setSetting(key, sel.value)); wrap.appendChild(sel); return wrap; } function renderAccountSection() { const sec = el("section", "data-section"); sec.appendChild(el("h3", "", t("account"))); const body = el("div", "data-body"); body.appendChild(el("p", "", t("account_desc", { u: app.me.username }))); const row = el("div", "form-row"); const cur = el("input", "file-input"); cur.type = "password"; cur.placeholder = t("current_pw"); const neu = el("input", "file-input"); neu.type = "password"; neu.placeholder = t("new_pw"); const btn = el("button", "btn", t("change_pw")); btn.type = "button"; btn.addEventListener("click", async () => { try { await api("/api/password", jsonOpts("POST", { current_password: cur.value, new_password: neu.value, })); cur.value = ""; neu.value = ""; toast(t("pw_changed"), "ok"); } catch (e) { flashError(e); } }); row.appendChild(cur); row.appendChild(neu); row.appendChild(btn); body.appendChild(row); const logoutRow = el("div", "form-row"); logoutRow.appendChild(logoutButton()); body.appendChild(logoutRow); sec.appendChild(body); return sec; } function renderUsersSection() { if (!app.me.username) return renderSetupUsersSection(); return renderOwnerUsersSection(); } function renderSetupUsersSection() { const sec = el("section", "data-section"); sec.appendChild(el("h3", "", t("setup_users"))); const body = el("div", "data-body"); body.appendChild(el("p", "", t("users_solo_desc"))); const row = el("div", "form-row"); const name = el("input", "file-input"); name.type = "text"; name.placeholder = t("username"); const pw = el("input", "file-input"); pw.type = "password"; pw.placeholder = t("password"); const add = el("button", "btn", t("first_user")); add.type = "button"; add.addEventListener("click", async () => { try { const r = await api("/api/register", jsonOpts("POST", { username: name.value, password: pw.value, })); localStorage.setItem("tp_token", r.token); toast(t("user_created", { u: r.username }), "ok"); await afterAuth(); // Einstellungen frisch rendern, falls gerade geoeffnet if ($("#view-settings").classList.contains("is-active")) { renderSettingsView(); } } catch (e) { if (e.status === 403) { toast(eMsg(e), "error"); setTimeout(() => afterAuth(), 800); } else { flashError(e); } } }); row.appendChild(name); row.appendChild(pw); row.appendChild(add); body.appendChild(row); sec.appendChild(body); return sec; } function renderOwnerUsersSection() { const sec = el("section", "data-section"); sec.appendChild(el("h3", "", t("users"))); const body = el("div", "data-body"); body.appendChild(el("p", "", t("users_desc"))); const list = el("ul", "user-list"); body.appendChild(list); const reload = async () => { list.innerHTML = ""; let users; try { users = await api("/api/users"); } catch (e) { flashError(e); return; } const others = users.filter(u => u.id !== 1); // Administrator nicht auflisten if (!others.length) { const li = el("li", "user-row", t("only_user")); li.classList.add("muted-only"); list.appendChild(li); } for (const u of others) { const li = el("li", "user-row", u.username); const del = el("button", "btn btn-small btn-danger-ghost", t("delete")); confirmify(del, async () => { try { await api("/api/users/" + u.id, { method: "DELETE" }); } catch (e) { flashError(e); return; } toast(t("user_deleted"), "ok"); reload(); }, t("sure")); li.appendChild(del); const pwBtn = el("button", "btn btn-small", t("change_pw")); pwBtn.type = "button"; pwBtn.addEventListener("click", () => { // Kein versehentlicher Abbruch: solange das Formular offen ist, // passiert beim erneuten Klick nichts; abbrechen geht ueber die // eigene Schaltflaeche. if (li.querySelector(".pw-reset")) return; const f = el("form", "pw-reset"); const inp = el("input", "file-input"); inp.type = "password"; inp.placeholder = t("new_pw"); const ok = el("button", "btn btn-small", t("apply")); ok.type = "submit"; const cancel = el("button", "btn btn-small", t("cancel_change")); cancel.type = "button"; f.appendChild(inp); f.appendChild(ok); f.appendChild(cancel); f.addEventListener("submit", ev => { ev.preventDefault(); api("/api/users/" + u.id + "/password", jsonOpts("PUT", { password: inp.value })) .then(() => { toast(t("pw_set", { u: u.username }), "ok"); f.remove(); }) .catch(e => flashError(e)); }); cancel.addEventListener("click", () => f.remove()); li.appendChild(f); }); li.appendChild(pwBtn); list.appendChild(li); } }; reload(); body.appendChild(el("div", "divider")); const row = el("div", "form-row"); const name = el("input", "file-input"); name.type = "text"; name.placeholder = t("username"); const pw = el("input", "file-input"); pw.type = "password"; pw.placeholder = t("password"); const add = el("button", "btn", t("create")); add.type = "button"; add.addEventListener("click", async () => { try { const r = await api("/api/users", jsonOpts("POST", { username: name.value, password: pw.value, })); toast(t("user_created", { u: r.username }), "ok"); name.value = ""; pw.value = ""; reload(); } catch (e) { flashError(e); } }); row.appendChild(name); row.appendChild(pw); row.appendChild(add); body.appendChild(row); sec.appendChild(body); return sec; } function renderResetSection() { const sec = el("section", "data-section"); sec.appendChild(el("h3", "", t("reset_server"))); const body = el("div", "data-body"); body.appendChild(el("p", "", t("reset_server_desc"))); const row = el("div", "form-row"); let pw = null; if (app.me.username) { pw = el("input", "file-input"); pw.type = "password"; pw.placeholder = t("current_pw"); } const conf = el("input", "file-input"); conf.type = "text"; conf.placeholder = t("reset_confirm_hint"); const btn = el("button", "btn btn-danger-ghost", t("reset_server_go")); btn.type = "button"; btn.addEventListener("click", async () => { try { const body_pw = pw ? pw.value : ""; await api("/api/reset", jsonOpts("POST", { password: body_pw, confirm: conf.value.trim(), })); toast(t("reset_done"), "accent"); await afterAuth(); } catch (e) { flashError(e); } }); if (pw) row.appendChild(pw); row.appendChild(conf); row.appendChild(btn); body.appendChild(row); sec.appendChild(body); return sec; } function toggleRow(key, label, desc) { const row = el("label", "toggle-row"); const cb = el("input", "toggle-input"); cb.type = "checkbox"; cb.checked = !!app.settings[key]; cb.addEventListener("change", () => setSetting(key, cb.checked)); const txt = el("span", "toggle-text"); txt.appendChild(el("strong", "", label)); if (desc) txt.appendChild(el("span", "muted", desc)); row.appendChild(cb); row.appendChild(txt); return row; } let uiSetupDone = false; function setupUi() { if (uiSetupDone) return; uiSetupDone = true; for (const tab of $$(".tab")) { tab.addEventListener("click", () => switchView(tab.dataset.view)); } setupOverlay(); setupThemeWatch(); } async function init() { app.meta = await api("/api/meta"); try { app.me = await api("/api/me"); } catch (e) { if (e.status === 401) { app.settings = { language: app.meta.language || "de" }; renderAuth(); return; } throw e; } setupUi(); await loadAppData(); } function updateTodayLabel() { const label = $("#today-label"); if (!label) return; const active = Object.values(app.models) .find(m => m.id && m.date === app.meta.today); if (active) { const a = cfgActivity(active.activity); const day = active.day ? " · " + active.day : ""; label.textContent = fmtDateLong(app.meta.today) + day + " " + t("session_active"); } else { label.textContent = fmtDateLong(app.meta.today); } } async function loadAppData() { app.cfg = await api("/api/config"); app.settings = await api("/api/settings"); applySettings(app.settings); for (const tab of $$(".tab")) { const key = "tab_" + tab.dataset.view; if (LANG[lang()][key]) tab.textContent = t(key); } updateTodayLabel(); await loadToday(); const startTab = app.settings.startTab || "today"; if (startTab !== "today") switchView(startTab); if (app.meta.users === 0 && !app.settings.setupDone) { renderSetupWizard("setup"); } else if (app.me.username && !app.settings.onboarded) { renderSetupWizard("onboarding"); } } /* Nach An-/Abmeldung in-place neu laden - kein location.reload, damit die Anmeldung auch in eingebetteten/sandboxed Umgebungen sofort greift. */ async function afterAuth() { document.body.classList.remove("no-auth"); for (const id of ["#auth-gate", "#setup-gate"]) { $(id).hidden = true; $(id).innerHTML = ""; } app.models = {}; try { await init(); } catch (e) { flashError(e); } } init().catch(e => { $("#view-today").appendChild(el("div", "empty-box", t("app_load_fail", { m: e.message }))); }).finally(() => { document.body.classList.remove("boot"); });