"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 sorted = byEx[exKey].sort((x, y) => x.sort - y.sort)
.filter(e => e.done);
if (!sorted.length) continue;
const li = el("li", "hist-entry");
const name = exKey.includes("/")
? exKey.split("/").slice(1).join("/") : exKey;
li.appendChild(el("span", "ex-name", name + ": "));
const setsWrap = el("span", "hist-sets");
setsWrap.textContent = sorted.map(e =>
(e.weight || t("empty_label")) + " " + unit + " × "
+ (e.reps || t("empty_label")) + " " + repsUnit).join(" · ");
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");
});