2358 lines
81 KiB
JavaScript
2358 lines
81 KiB
JavaScript
"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");
|
||
}); |