-
This commit is contained in:
@@ -0,0 +1,678 @@
|
||||
const appState = {
|
||||
data: null,
|
||||
userId: localStorage.getItem("nht:userId") || "",
|
||||
view: localStorage.getItem("nht:view") || "weekly",
|
||||
date: localStorage.getItem("nht:date") || new Date().toISOString().slice(0, 10),
|
||||
};
|
||||
|
||||
const accentChoices = ["green", "blue", "red", "pink", "orange"];
|
||||
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function number(value, digits = 0) {
|
||||
const numeric = Number(value || 0);
|
||||
return numeric.toLocaleString(undefined, {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
function shortDate(value) {
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function longDate(value) {
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function addDays(dateString, days) {
|
||||
const date = new Date(`${dateString}T00:00:00`);
|
||||
date.setDate(date.getDate() + days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function dateRange(start, end) {
|
||||
const days = [];
|
||||
let cursor = start;
|
||||
while (cursor <= end) {
|
||||
days.push(cursor);
|
||||
cursor = addDays(cursor, 1);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const toast = $("#toast");
|
||||
toast.textContent = message;
|
||||
toast.classList.add("show");
|
||||
window.clearTimeout(showToast.timer);
|
||||
showToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2400);
|
||||
}
|
||||
|
||||
async function api(action, payload = null, method = "POST") {
|
||||
const options = { method };
|
||||
if (payload) {
|
||||
options.headers = { "Content-Type": "application/json" };
|
||||
options.body = JSON.stringify(payload);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api.php?action=${encodeURIComponent(action)}`, options);
|
||||
const json = await response.json();
|
||||
if (!response.ok || json.error) {
|
||||
throw new Error(json.error || "Request failed.");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
async function loadState() {
|
||||
const params = new URLSearchParams({
|
||||
action: "state",
|
||||
view: appState.view,
|
||||
date: appState.date,
|
||||
});
|
||||
|
||||
if (appState.userId) {
|
||||
params.set("user_id", appState.userId);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api.php?${params.toString()}`);
|
||||
const json = await response.json();
|
||||
if (!response.ok || json.error) {
|
||||
throw new Error(json.error || "Unable to load tracker state.");
|
||||
}
|
||||
|
||||
appState.data = json;
|
||||
appState.userId = String(json.currentUser.id);
|
||||
appState.view = json.view;
|
||||
appState.date = json.range.anchor;
|
||||
localStorage.setItem("nht:userId", appState.userId);
|
||||
localStorage.setItem("nht:view", appState.view);
|
||||
localStorage.setItem("nht:date", appState.date);
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!appState.data) return;
|
||||
|
||||
applyTheme();
|
||||
renderControls();
|
||||
renderSummaries();
|
||||
renderTimeline();
|
||||
renderNutrition();
|
||||
renderRoutines();
|
||||
renderSupplements();
|
||||
renderAdmin();
|
||||
renderLibraries();
|
||||
populateForms();
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
const user = appState.data.currentUser;
|
||||
const mode = user.theme_mode || "dark";
|
||||
const accent = accentChoices.includes(user.theme_accent) ? user.theme_accent : "green";
|
||||
|
||||
document.body.dataset.theme = mode;
|
||||
document.body.dataset.accent = accent;
|
||||
|
||||
$("#darkModeButton").classList.toggle("active", mode === "dark");
|
||||
$("#lightModeButton").classList.toggle("active", mode === "light");
|
||||
$$("[data-accent]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.accent === accent);
|
||||
});
|
||||
}
|
||||
|
||||
function renderControls() {
|
||||
const data = appState.data;
|
||||
const user = data.currentUser;
|
||||
$("#profileFocus").textContent = user.fitness_focus || "Multi-profile training dashboard";
|
||||
if (data.currentUserIsAdmin) {
|
||||
$("#profileFocus").textContent += " / admin";
|
||||
}
|
||||
$("#rangeCopy").textContent = `${data.view[0].toUpperCase()}${data.view.slice(1)} view: ${longDate(data.range.start)} to ${longDate(data.range.end)}`;
|
||||
$("#datePicker").value = data.range.anchor;
|
||||
|
||||
$("#profileSelect").innerHTML = data.users
|
||||
.map((profile) => `<option value="${profile.id}" ${profile.id === user.id ? "selected" : ""}>${escapeHtml(profile.name)} (${escapeHtml(profile.role)})</option>`)
|
||||
.join("");
|
||||
|
||||
$$("[data-view]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.view === data.view);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSummaries() {
|
||||
const { workoutSummary, nutritionSummary, latestMetric, supplementLogs } = appState.data;
|
||||
const actual = nutritionSummary.actual;
|
||||
const target = nutritionSummary.target;
|
||||
const supplementTaken = supplementLogs.filter((item) => item.status === "taken").length;
|
||||
|
||||
const cards = [
|
||||
["Completion", `${number(workoutSummary.completion_rate, 1)}%`, `${workoutSummary.completed}/${workoutSummary.planned} workouts`],
|
||||
["Volume", number(workoutSummary.weight_volume_lbs), "lb lifted"],
|
||||
["Cardio", `${number(workoutSummary.minutes, 0)} min`, `${number(workoutSummary.distance_mi, 2)} mi`],
|
||||
["Calories", number(actual.calories), `${number(target.calories)} target`],
|
||||
["Protein", `${number(actual.protein_g)} g`, `${number(target.protein_g)} g target`],
|
||||
["Weight", latestMetric ? `${number(latestMetric.weight_lbs, 1)} lb` : "No log", latestMetric ? `${number(latestMetric.sleep_hours, 1)} hr sleep` : `${supplementTaken} doses taken`],
|
||||
];
|
||||
|
||||
$("#summaryGrid").innerHTML = cards
|
||||
.map(([label, value, detail]) => `
|
||||
<article class="summary-card">
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<strong>${escapeHtml(value)}</strong>
|
||||
<small>${escapeHtml(detail)}</small>
|
||||
</article>
|
||||
`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderTimeline() {
|
||||
const { range, workouts } = appState.data;
|
||||
const byDate = new Map();
|
||||
workouts.forEach((workout) => {
|
||||
if (!byDate.has(workout.scheduled_on)) byDate.set(workout.scheduled_on, []);
|
||||
byDate.get(workout.scheduled_on).push(workout);
|
||||
});
|
||||
|
||||
const html = dateRange(range.start, range.end).map((date) => {
|
||||
const entries = byDate.get(date) || [];
|
||||
const volume = entries.reduce((sum, workout) => sum + Number(workout.sets || 0) * Number(workout.reps || 0) * Number(workout.weight_lbs || 0), 0);
|
||||
const minutes = entries.reduce((sum, workout) => sum + Number(workout.duration_min || 0), 0);
|
||||
return `
|
||||
<section class="timeline-day">
|
||||
<div class="timeline-date">
|
||||
<strong>${longDate(date)}</strong>
|
||||
<span class="timeline-meta">${entries.length} workouts / ${number(minutes)} min / ${number(volume)} lb</span>
|
||||
</div>
|
||||
<div class="workout-list">
|
||||
${entries.length ? entries.map(renderWorkoutItem).join("") : `<div class="empty-state">No workouts scheduled</div>`}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
$("#workoutTimeline").innerHTML = html || `<div class="empty-state">No planner days found</div>`;
|
||||
}
|
||||
|
||||
function renderWorkoutItem(workout) {
|
||||
const liftLine = Number(workout.weight_lbs) > 0
|
||||
? `${number(workout.sets)} x ${number(workout.reps)} @ ${number(workout.weight_lbs, 1)} lb`
|
||||
: "";
|
||||
const cardioLine = Number(workout.duration_min) || Number(workout.distance_mi)
|
||||
? `${number(workout.duration_min)} min${Number(workout.distance_mi) ? ` / ${number(workout.distance_mi, 2)} mi` : ""}`
|
||||
: "";
|
||||
const details = [workout.workout_type, workout.exercise_name, liftLine, cardioLine, workout.intensity]
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
|
||||
return `
|
||||
<article class="workout-item">
|
||||
<div class="workout-top">
|
||||
<div>
|
||||
<h3>${escapeHtml(workout.title)}</h3>
|
||||
<p class="details-line">${escapeHtml(details || "Open workout")}</p>
|
||||
${workout.routine_name ? `<p class="timeline-meta">${escapeHtml(workout.routine_name)}</p>` : ""}
|
||||
</div>
|
||||
<span class="badge ${escapeHtml(workout.status)}">${escapeHtml(workout.status)}</span>
|
||||
</div>
|
||||
${workout.notes ? `<p class="muted">${escapeHtml(workout.notes)}</p>` : ""}
|
||||
<div class="status-row">
|
||||
${["planned", "complete", "skipped"].map((status) => `
|
||||
<button class="status-button" type="button" data-workout-id="${workout.id}" data-status="${status}">
|
||||
${status}
|
||||
</button>
|
||||
`).join("")}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderNutrition() {
|
||||
const { nutritionSummary, nutritionLogs } = appState.data;
|
||||
const target = nutritionSummary.target;
|
||||
const actual = nutritionSummary.actual;
|
||||
const macros = [
|
||||
["Calories", "calories", "kcal"],
|
||||
["Protein", "protein_g", "g"],
|
||||
["Carbs", "carbs_g", "g"],
|
||||
["Fat", "fat_g", "g"],
|
||||
["Water", "water_ml", "ml"],
|
||||
];
|
||||
|
||||
const macroHtml = macros.map(([label, key, unit]) => {
|
||||
const targetValue = Number(target[key] || 0);
|
||||
const actualValue = Number(actual[key] || 0);
|
||||
const percent = targetValue > 0 ? Math.min((actualValue / targetValue) * 100, 140) : 0;
|
||||
return `
|
||||
<div class="macro-row">
|
||||
<div class="macro-copy">
|
||||
<span>${label}</span>
|
||||
<span>${number(actualValue)} / ${number(targetValue)} ${unit}</span>
|
||||
</div>
|
||||
<div class="bar" style="--value: ${percent}%"><span></span></div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
const logHtml = nutritionLogs.length
|
||||
? nutritionLogs.map((log) => `
|
||||
<article class="log-item">
|
||||
<div class="log-top">
|
||||
<strong>${shortDate(log.logged_on)}</strong>
|
||||
<span class="badge">${number(log.calories)} kcal</span>
|
||||
</div>
|
||||
<p>${number(log.protein_g)}g protein / ${number(log.carbs_g)}g carbs / ${number(log.fat_g)}g fat / ${number(log.water_ml)}ml water</p>
|
||||
${log.notes ? `<p class="muted">${escapeHtml(log.notes)}</p>` : ""}
|
||||
</article>
|
||||
`).join("")
|
||||
: `<div class="empty-state">No nutrition logs in this range</div>`;
|
||||
|
||||
$("#nutritionCompare").innerHTML = `
|
||||
<div class="macro-list">${macroHtml}</div>
|
||||
<div class="panel-heading" style="margin-top: 16px;">
|
||||
<div>
|
||||
<p class="eyebrow">Selected Target</p>
|
||||
<h3>${escapeHtml(target.label)} (${escapeHtml(target.scope)})</h3>
|
||||
</div>
|
||||
<span class="badge">${escapeHtml(target.scaled_from)} basis</span>
|
||||
</div>
|
||||
<div class="nutrition-list">${logHtml}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRoutines() {
|
||||
const routines = appState.data.routines;
|
||||
$("#routineList").innerHTML = routines.length
|
||||
? routines.map((routine) => `
|
||||
<article class="routine-item">
|
||||
<div class="routine-top">
|
||||
<div>
|
||||
<h3>${escapeHtml(routine.name)}</h3>
|
||||
<p class="routine-meta">${escapeHtml(routine.workout_type)} / ${escapeHtml(routine.planner_scope)} / ${escapeHtml(routine.intensity || "open intensity")}</p>
|
||||
</div>
|
||||
<span class="badge">${routine.active ? "active" : "paused"}</span>
|
||||
</div>
|
||||
<p class="details-line">Nutrition: ${escapeHtml(routine.nutrition_goal_label || "separate target")}</p>
|
||||
${routine.notes ? `<p class="muted">${escapeHtml(routine.notes)}</p>` : ""}
|
||||
</article>
|
||||
`).join("")
|
||||
: `<div class="empty-state">No routines yet</div>`;
|
||||
}
|
||||
|
||||
function renderSupplements() {
|
||||
const logs = appState.data.supplementLogs;
|
||||
$("#supplementLogList").innerHTML = logs.length
|
||||
? logs.map((log) => `
|
||||
<article class="supplement-item">
|
||||
<div class="supplement-top">
|
||||
<div>
|
||||
<h3>${escapeHtml(log.supplement_name || "Custom supplement")}</h3>
|
||||
<p class="details-line">${shortDate(log.taken_on)} / ${escapeHtml(log.timing)} / ${escapeHtml(log.dose || "open dose")}</p>
|
||||
</div>
|
||||
<span class="badge ${escapeHtml(log.status)}">${escapeHtml(log.status)}</span>
|
||||
</div>
|
||||
${log.supplement_purpose ? `<p>${escapeHtml(log.supplement_purpose)}</p>` : ""}
|
||||
${log.notes ? `<p class="muted">${escapeHtml(log.notes)}</p>` : ""}
|
||||
</article>
|
||||
`).join("")
|
||||
: `<div class="empty-state">No supplement logs in this range</div>`;
|
||||
}
|
||||
|
||||
function renderAdmin() {
|
||||
const panel = $("#adminPanel");
|
||||
if (!panel) return;
|
||||
|
||||
const isAdminUser = Boolean(appState.data.currentUserIsAdmin);
|
||||
panel.hidden = !isAdminUser;
|
||||
if (!isAdminUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = appState.data.adminSummary || {};
|
||||
const statCards = [
|
||||
["Total", summary.total_users || 0],
|
||||
["Active", summary.active_users || 0],
|
||||
["Inactive", summary.inactive_users || 0],
|
||||
["Admins", summary.admins || 0],
|
||||
["Members", summary.members || 0],
|
||||
];
|
||||
$("#adminStats").innerHTML = statCards.map(([label, value]) => `
|
||||
<article class="admin-stat">
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<strong>${number(value)}</strong>
|
||||
</article>
|
||||
`).join("");
|
||||
|
||||
const currentId = Number(appState.data.currentUser.id);
|
||||
const rows = appState.data.adminUsers || [];
|
||||
$("#adminUserList").innerHTML = rows.length
|
||||
? rows.map((user) => {
|
||||
const isCurrent = Number(user.id) === currentId;
|
||||
const toggleStatus = user.status === "active" ? "inactive" : "active";
|
||||
const training = user.training_level ? ` / ${user.training_level}` : "";
|
||||
const height = Number(user.height_in) > 0 ? ` / ${number(user.height_in, 1)} in` : "";
|
||||
return `
|
||||
<article class="admin-user-card" data-admin-user-card="${user.id}">
|
||||
<div class="admin-user-main">
|
||||
<div>
|
||||
<h3>${escapeHtml(user.name)}${isCurrent ? " (current)" : ""}</h3>
|
||||
<p class="details-line">${escapeHtml(user.email || "No email")}</p>
|
||||
<p class="details-line">${escapeHtml(user.fitness_focus || "No focus set")}</p>
|
||||
<p class="timeline-meta">${escapeHtml(user.phone || "No phone")}${escapeHtml(training)}${escapeHtml(height)}</p>
|
||||
</div>
|
||||
<div class="admin-badges">
|
||||
<span class="badge ${escapeHtml(user.role)}">${escapeHtml(user.role)}</span>
|
||||
<span class="badge ${escapeHtml(user.status)}">${escapeHtml(user.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-user-metrics">
|
||||
<span>${number(user.routine_count)} routines</span>
|
||||
<span>${number(user.workout_count)} workouts</span>
|
||||
<span>${number(user.nutrition_log_count)} nutrition logs</span>
|
||||
<span>${number(user.supplement_log_count)} supplement logs</span>
|
||||
<span>${number(user.metric_count)} metrics</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<button class="status-button" type="button" data-admin-action="edit" data-admin-user-id="${user.id}">Edit</button>
|
||||
<button class="status-button" type="button" data-admin-action="status" data-admin-user-id="${user.id}" data-next-status="${toggleStatus}">
|
||||
${toggleStatus === "active" ? "Activate" : "Deactivate"}
|
||||
</button>
|
||||
<button class="status-button danger-action" type="button" data-admin-action="delete" data-admin-user-id="${user.id}" ${isCurrent ? "disabled" : ""}>Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join("")
|
||||
: `<div class="empty-state">No profiles found</div>`;
|
||||
}
|
||||
|
||||
function resetAdminUserForm() {
|
||||
const form = $("#adminUserForm");
|
||||
if (!form) return;
|
||||
form.reset();
|
||||
form.elements.target_user_id.value = "";
|
||||
form.elements.role.value = "member";
|
||||
form.elements.status.value = "active";
|
||||
form.elements.theme_mode.value = "dark";
|
||||
form.elements.theme_accent.value = "green";
|
||||
$("#adminUserFormTitle").textContent = "Create Profile";
|
||||
}
|
||||
|
||||
function fillAdminUserForm(user) {
|
||||
const form = $("#adminUserForm");
|
||||
if (!form || !user) return;
|
||||
|
||||
form.elements.target_user_id.value = user.id || "";
|
||||
form.elements.name.value = user.name || "";
|
||||
form.elements.email.value = user.email || "";
|
||||
form.elements.role.value = user.role || "member";
|
||||
form.elements.status.value = user.status || "active";
|
||||
form.elements.phone.value = user.phone || "";
|
||||
form.elements.birthday.value = user.birthday || "";
|
||||
form.elements.height_in.value = Number(user.height_in || 0) > 0 ? user.height_in : "";
|
||||
form.elements.training_level.value = user.training_level || "";
|
||||
form.elements.theme_mode.value = user.theme_mode || "dark";
|
||||
form.elements.theme_accent.value = user.theme_accent || "green";
|
||||
form.elements.fitness_focus.value = user.fitness_focus || "";
|
||||
form.elements.emergency_contact.value = user.emergency_contact || "";
|
||||
$("#adminUserFormTitle").textContent = `Edit ${user.name}`;
|
||||
}
|
||||
|
||||
function renderLibraries() {
|
||||
$("#exerciseLibrary").innerHTML = appState.data.exercises.map((exercise) => `
|
||||
<span class="chip">
|
||||
${escapeHtml(exercise.name)}
|
||||
<small>${escapeHtml(exercise.category)} / ${escapeHtml(exercise.default_unit || "custom")}</small>
|
||||
</span>
|
||||
`).join("");
|
||||
|
||||
$("#supplementLibrary").innerHTML = appState.data.supplements.map((supplement) => `
|
||||
<span class="chip">
|
||||
${escapeHtml(supplement.name)}
|
||||
<small>${escapeHtml(supplement.timing)} / ${escapeHtml(supplement.default_dose || "custom")}</small>
|
||||
</span>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function option(value, label, selected = false) {
|
||||
return `<option value="${escapeHtml(value)}" ${selected ? "selected" : ""}>${escapeHtml(label)}</option>`;
|
||||
}
|
||||
|
||||
function populateForms() {
|
||||
const { routines, exercises, supplements, nutritionGoals, range } = appState.data;
|
||||
const routineOptions = option("", "No routine") + routines.map((routine) => option(routine.id, `${routine.name} (${routine.planner_scope})`)).join("");
|
||||
const goalOptions = option("", "Separate target") + nutritionGoals.map((goal) => option(goal.id, `${goal.label} (${goal.scope})`)).join("");
|
||||
const exerciseOptions = option("", "Choose exercise") + exercises.map((exercise) => option(exercise.id, `${exercise.name} (${exercise.category})`)).join("");
|
||||
const supplementOptions = option("", "Choose supplement") + supplements.map((supplement) => option(supplement.id, `${supplement.name} (${supplement.timing})`)).join("");
|
||||
|
||||
$$("#workoutForm [name='routine_id']").forEach((select) => select.innerHTML = routineOptions);
|
||||
$$("#routineForm [name='nutrition_goal_id']").forEach((select) => select.innerHTML = goalOptions);
|
||||
$$("#workoutForm [name='exercise_id']").forEach((select) => select.innerHTML = exerciseOptions);
|
||||
$$("#supplementLogForm [name='supplement_id']").forEach((select) => select.innerHTML = supplementOptions);
|
||||
|
||||
const dateFields = [
|
||||
"#workoutForm [name='scheduled_on']",
|
||||
"#routineForm [name='start_date']",
|
||||
"#nutritionGoalForm [name='start_date']",
|
||||
"#nutritionLogForm [name='logged_on']",
|
||||
"#supplementLogForm [name='taken_on']",
|
||||
"#metricForm [name='measured_on']",
|
||||
];
|
||||
dateFields.forEach((selector) => {
|
||||
const input = $(selector);
|
||||
if (input && !input.value) input.value = range.anchor;
|
||||
});
|
||||
}
|
||||
|
||||
function formPayload(form) {
|
||||
const payload = Object.fromEntries(new FormData(form).entries());
|
||||
payload.user_id = appState.data.currentUser.id;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function adminUserPayload(form) {
|
||||
const payload = Object.fromEntries(new FormData(form).entries());
|
||||
payload.acting_user_id = appState.data.currentUser.id;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function wireForm(selector, action, message, afterSave = null) {
|
||||
const form = $(selector);
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const result = await api(action, formPayload(form));
|
||||
if (afterSave) afterSave(result);
|
||||
form.reset();
|
||||
await loadState();
|
||||
showToast(message);
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function wireControls() {
|
||||
$("#profileSelect").addEventListener("change", async (event) => {
|
||||
appState.userId = event.target.value;
|
||||
localStorage.setItem("nht:userId", appState.userId);
|
||||
await loadState();
|
||||
});
|
||||
|
||||
$("#datePicker").addEventListener("change", async (event) => {
|
||||
appState.date = event.target.value;
|
||||
localStorage.setItem("nht:date", appState.date);
|
||||
await loadState();
|
||||
});
|
||||
|
||||
$$("[data-view]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
appState.view = button.dataset.view;
|
||||
localStorage.setItem("nht:view", appState.view);
|
||||
await loadState();
|
||||
});
|
||||
});
|
||||
|
||||
$("#darkModeButton").addEventListener("click", () => saveTheme("dark"));
|
||||
$("#lightModeButton").addEventListener("click", () => saveTheme("light"));
|
||||
|
||||
$$("[data-accent]").forEach((button) => {
|
||||
button.addEventListener("click", () => saveTheme(null, button.dataset.accent));
|
||||
});
|
||||
|
||||
$("#workoutTimeline").addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("[data-workout-id]");
|
||||
if (!button) return;
|
||||
try {
|
||||
await api("update_workout", {
|
||||
id: button.dataset.workoutId,
|
||||
status: button.dataset.status,
|
||||
});
|
||||
await loadState();
|
||||
showToast("Workout updated");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const jump = event.target.closest("[data-jump]");
|
||||
if (!jump) return;
|
||||
const target = $(jump.dataset.jump);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
});
|
||||
|
||||
$("#workoutForm [name='exercise_id']").addEventListener("change", (event) => {
|
||||
const exercise = appState.data.exercises.find((item) => String(item.id) === event.target.value);
|
||||
const title = $("#workoutForm [name='title']");
|
||||
const type = $("#workoutForm [name='workout_type']");
|
||||
if (exercise && !title.value) title.value = exercise.name;
|
||||
if (exercise) type.value = exercise.category;
|
||||
});
|
||||
|
||||
$("#supplementLogForm [name='supplement_id']").addEventListener("change", (event) => {
|
||||
const supplement = appState.data.supplements.find((item) => String(item.id) === event.target.value);
|
||||
if (!supplement) return;
|
||||
$("#supplementLogForm [name='timing']").value = supplement.timing;
|
||||
$("#supplementLogForm [name='dose']").value = supplement.default_dose;
|
||||
});
|
||||
|
||||
$("#newAdminUserButton").addEventListener("click", () => {
|
||||
resetAdminUserForm();
|
||||
$("#adminUserForm").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
|
||||
$("#clearAdminUserFormButton").addEventListener("click", resetAdminUserForm);
|
||||
|
||||
$("#adminUserList").addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("[data-admin-action]");
|
||||
if (!button) return;
|
||||
|
||||
const user = (appState.data.adminUsers || []).find((item) => String(item.id) === button.dataset.adminUserId);
|
||||
if (!user) return;
|
||||
|
||||
if (button.dataset.adminAction === "edit") {
|
||||
fillAdminUserForm(user);
|
||||
$("#adminUserForm").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.dataset.adminAction === "status") {
|
||||
try {
|
||||
await api("update_user", {
|
||||
acting_user_id: appState.data.currentUser.id,
|
||||
target_user_id: user.id,
|
||||
status: button.dataset.nextStatus,
|
||||
});
|
||||
await loadState();
|
||||
showToast("Profile status updated");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.dataset.adminAction === "delete") {
|
||||
const ok = window.confirm(`Delete ${user.name} and all of this profile's tracker data?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api("delete_user", {
|
||||
acting_user_id: appState.data.currentUser.id,
|
||||
target_user_id: user.id,
|
||||
});
|
||||
await loadState();
|
||||
showToast("Profile deleted");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function saveTheme(mode = null, accent = null) {
|
||||
const current = appState.data.currentUser;
|
||||
try {
|
||||
await api("update_theme", {
|
||||
user_id: current.id,
|
||||
theme_mode: mode || current.theme_mode || "dark",
|
||||
theme_accent: accent || current.theme_accent || "green",
|
||||
});
|
||||
await loadState();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function wireForms() {
|
||||
wireForm("#workoutForm", "create_workout", "Workout saved");
|
||||
wireForm("#routineForm", "create_routine", "Routine saved");
|
||||
wireForm("#nutritionGoalForm", "create_nutrition_goal", "Nutrition target saved");
|
||||
wireForm("#nutritionLogForm", "create_nutrition_log", "Nutrition logged");
|
||||
wireForm("#supplementLogForm", "create_supplement_log", "Supplement logged");
|
||||
wireForm("#metricForm", "create_body_metric", "Metrics logged");
|
||||
wireForm("#exerciseForm", "create_exercise", "Exercise added");
|
||||
wireForm("#supplementForm", "create_supplement", "Supplement added");
|
||||
|
||||
$("#adminUserForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const payload = adminUserPayload(form);
|
||||
const action = payload.target_user_id ? "update_user" : "create_user";
|
||||
try {
|
||||
await api(action, payload);
|
||||
resetAdminUserForm();
|
||||
await loadState();
|
||||
showToast(action === "create_user" ? "Profile created" : "Profile updated");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
wireControls();
|
||||
wireForms();
|
||||
try {
|
||||
await loadState();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user