Files
trainingsplan/app.js
T
AI-Agent 8fc77212cb Startpunkt 02.10.2026: aktueller Stand des Trainingsplans
Historie bewusst neu aufgesetzt, ein einziger Commit als neuer Ausgangspunkt.
2026-10-02 01:23:25 +02:00

2358 lines
81 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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",
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.",
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.",
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",
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.",
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.",
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);
for (const k of model.exKeys) {
const ex = model.exByKey[k] || {
key: k, name: k, sets: { count: 0 }, target: "", startKg: "",
info: "", tags: [],
};
card.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);
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));
head.appendChild(resetAll);
}
const fin = el("button", "btn btn-primary", t("finish_session"));
fin.type = "button";
fin.addEventListener("click", () => finishSession(a, model));
head.appendChild(fin);
head.appendChild(discardBtn(a, model));
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 card = app.cards[model.activity];
if (card) card.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 card = app.cards[model.activity];
if (card) {
const blocks = keys.map(k => model.exBlocks[k]).filter(Boolean);
for (const b of blocks) card.appendChild(b);
}
model.fields = Object.assign({}, model.fields, { exerciseOrder: keys.slice() });
model.metaDirty = true;
scheduleAutosave(model);
}
function attachDragReorder(a, model, ex, block) {
block.draggable = true;
block.title = t("reorder_hint");
block.addEventListener("dragstart", ev => {
app.dragKey = ex.key;
try {
ev.dataTransfer = ev.dataTransfer || {};
ev.dataTransfer.setData("text/plain", ex.key);
} catch (_) {}
});
block.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 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);
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");
const reset = el("button", "btn btn-small", t("reset_set"));
reset.type = "button";
reset.setAttribute("aria-label", t("reset_set_title"));
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 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,
}));
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");
li.appendChild(el("span", "ex-name", exKey + ": "));
const setsWrap = el("span", "hist-sets");
const sorted = byEx[exKey].sort((x, y) => x.sort - y.sort);
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, userCreated: false };
function renderSetupWizard(mode) {
wizard.mode = mode;
wizard.step = 0; // Sprache zuerst
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.userCreated = false;
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.mode === "setup" && wizard.step === 1) {
if (wizard.multi === null) return false;
return wizard.multi === false || wizard.userCreated;
}
return !!wizard.theme && !!wizard.accent;
}
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);
if (wizard.step === 0) {
// Schritt 0: Sprache (zuerst)
box.appendChild(el("h3", "wizard-title", t("lang_step")));
if (wizard.mode === "setup") {
box.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);
}
box.appendChild(row);
} else if (wizard.step === 1 && wizard.mode === "setup") {
// Schritt 1: Einzel- oder Mehrbenutzermodus
box.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);
box.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"));
const err = el("div", "auth-error", "");
err.hidden = true;
const submit = el("button", "btn btn-primary btn-block", t("first_user"));
submit.type = "submit";
form.appendChild(submit);
form.appendChild(err);
form.addEventListener("submit", ev => {
ev.preventDefault();
wizardRegister($("#setup-user").value, $("#setup-pass").value, err);
});
box.appendChild(form);
}
} else {
// Letzter Schritt: Darstellung (Theme + OLED + Akzent)
box.appendChild(el("h3", "wizard-title", t("appearance")));
if (wizard.mode === "setup") {
box.appendChild(el("p", "auth-sub", t("color_server")));
}
box.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);
}
box.appendChild(themeRow);
box.appendChild(wizardToggle("oled", t("oled_toggle"), t("oled_desc"), wizard.oled,
previewWizardAppearance));
box.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);
}
box.appendChild(accentRow);
}
// 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, setupDone: true,
};
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();
}
async function wizardRegister(username, password, errEl) {
try {
const r = await api("/api/register", jsonOpts("POST", {
username, password,
}));
localStorage.setItem("tp_token", r.token);
app.me = { username: r.username, is_owner: true };
wizard.userCreated = true;
renderWizardStep();
} catch (e) {
if (e.status === 403) {
// Ein Benutzer existiert inzwischen (z.B. zweiter Tab): neu laden
if (errEl) errEl.textContent = eMsg(e);
setTimeout(() => afterAuth(), 600);
} else if (errEl) {
errEl.hidden = false;
errEl.textContent = eMsg(e);
} else {
flashError(e);
}
}
}
/* ---------------------------------------------------------------------------
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 setupLogout() {
const btn = $("#logout-btn");
btn.addEventListener("click", async () => {
try {
await api("/api/logout", { method: "POST" });
} catch (_) {}
localStorage.removeItem("tp_token");
app.me = null;
app.models = {};
$("#view-today").innerHTML = "";
$("#today-label").textContent = "";
app.meta = await api("/api/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 setUserChip() {
const chip = $("#user-chip");
const btn = $("#logout-btn");
if (app.me && app.me.username) {
chip.hidden = false;
chip.textContent = app.me.username;
btn.hidden = false;
}
}
/* ---------------------------------------------------------------------------
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());
}
}
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", "data-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);
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", "data-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();
} 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();
const row = el("div", "data-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 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();
setupLogout();
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();
}
async function loadAppData() {
app.cfg = await api("/api/config");
app.settings = await api("/api/settings");
applySettings(app.settings);
$("#today-label").textContent = t("today_header") + fmtDateLong(app.meta.today);
setUserChip();
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");
});