Files
premier-gunner/public/js/app.js
T

367 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { api } from '/js/api.js';
import { renderStats } from '/js/dashboard.js';
// ---------- tiny DOM + date helpers ----------
export const h = (tag, attrs = {}, ...kids) => {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') e.className = v;
else if (k === 'html') e.innerHTML = v;
else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v);
else if (v === true) e.setAttribute(k, '');
else if (v !== false && v != null) e.setAttribute(k, v);
}
for (const kid of kids.flat()) {
if (kid == null || kid === false) continue;
e.append(kid.nodeType ? kid : document.createTextNode(kid));
}
return e;
};
const pad = (n) => String(n).padStart(2, '0');
export const isoOf = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
export const todayISO = () => isoOf(new Date());
export const parseISO = (s) => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); };
export const addDays = (iso, n) => { const d = parseISO(iso); d.setDate(d.getDate() + n); return isoOf(d); };
const WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export const prettyDay = (iso) => {
const d = parseISO(iso); const t = todayISO();
if (iso === t) return `Today · ${WEEK[d.getDay()]} ${MON[d.getMonth()]} ${d.getDate()}`;
if (iso === addDays(t, -1)) return `Yesterday · ${MON[d.getMonth()]} ${d.getDate()}`;
if (iso === addDays(t, 1)) return `Tomorrow · ${MON[d.getMonth()]} ${d.getDate()}`;
return `${WEEK[d.getDay()]} · ${MON[d.getMonth()]} ${d.getDate()}`;
};
export function toast(msg) {
document.querySelector('.toast')?.remove();
const t = h('div', { class: 'toast' }, msg);
document.body.append(t);
setTimeout(() => t.remove(), 1900);
}
const modalRoot = document.getElementById('modal-root');
export function openModal(title, contentEl, { onClose } = {}) {
closeModal();
const close = () => { modalRoot.innerHTML = ''; onClose?.(); };
const backdrop = h('div', { class: 'modal-backdrop', onclick: (e) => { if (e.target === backdrop) close(); } },
h('div', { class: 'modal' },
h('h2', {}, title, h('button', { class: 'close', onclick: close }, '✕')),
contentEl));
modalRoot.append(backdrop);
return close;
}
export const closeModal = () => { modalRoot.innerHTML = ''; };
const textColorFor = (hex) => {
const c = hex.replace('#', '');
const r = parseInt(c.substr(0, 2), 16), g = parseInt(c.substr(2, 2), 16), b = parseInt(c.substr(4, 2), 16);
return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? '#15181f' : '#fff';
};
// ---------- state ----------
const state = { categories: [], day: todayISO(), tab: 'today' };
const catById = (id) => state.categories.find((c) => c.id === id);
async function loadCategories() { state.categories = await api.categories(); }
// ---------- TODAY (logging) ----------
async function renderToday(view) {
const data = await api.day(state.day);
const loggedCatIds = new Set(data.entries.map((e) => e.category_id));
const plannedIds = new Set(data.plans.map((p) => p.category_id));
view.innerHTML = '';
view.append(
h('div', { class: 'datenav' },
h('button', { onclick: () => { state.day = addDays(state.day, -1); renderToday(view); } }, ''),
h('div', { class: 'day-label' }, prettyDay(state.day)),
h('button', { onclick: () => { state.day = addDays(state.day, 1); renderToday(view); } }, '')));
if (plannedIds.size) {
view.append(h('div', { class: 'card' },
h('h2', {}, '🗓️ Today\'s plan'),
h('div', { class: 'pill-grid' },
[...plannedIds].map((id) => {
const c = catById(id); if (!c) return null;
return h('span', { class: 'pill' + (loggedCatIds.has(id) ? ' done' : '') },
h('span', { class: 'emoji' }, c.emoji), c.name, loggedCatIds.has(id) ? ' ✅' : '');
}))));
}
view.append(h('div', { class: 'card' },
h('h2', {}, '⚽ What did you train?'),
h('p', { class: 'muted small' }, 'Tap a category to log it.'),
h('div', { class: 'pill-grid' },
state.categories.map((c) => {
const done = loggedCatIds.has(c.id);
const style = `background:${c.color};border-color:${c.color};color:${textColorFor(c.color)}`;
return h('button', {
class: 'pill' + (done ? ' done' : ''),
style: done ? style : '',
onclick: () => openLogModal(c, view),
}, h('span', { class: 'emoji' }, c.emoji), c.name, done ? ' ✅' : '');
}))));
// Logged entries for the day
const log = h('div', { class: 'card' }, h('h2', {}, '✅ Logged'));
if (!data.entries.length) {
log.append(h('p', { class: 'muted' }, 'Nothing yet — tap a category above to start!'));
} else {
for (const e of data.entries) {
const c = catById(e.category_id) || { emoji: '⚽', name: 'Category', metrics: [] };
const valStr = e.values.map((v) => {
const m = c.metrics?.find((mm) => mm.id === v.metric_id);
return m ? `${v.value} ${m.unit || m.name}` : v.value;
}).join(' · ');
log.append(h('div', { class: 'entry' },
h('span', { class: 'emoji' }, c.emoji),
h('div', { style: 'flex:1' }, h('div', {}, c.name), valStr && h('div', { class: 'vals' }, valStr)),
h('button', { class: 'btn-danger', onclick: async () => { await api.deleteEntry(e.id); renderToday(view); refreshStatsIfActive(); } }, '🗑')));
}
}
view.append(log);
// Daily notes
const ta = h('textarea', { rows: 3, placeholder: 'Notes, thoughts, things to remember…' }, data.notes || '');
view.append(h('div', { class: 'card' },
h('h2', {}, '📝 Notes for the day'),
ta,
h('div', { class: 'btn-row' },
h('button', { class: 'btn-primary', onclick: async () => { await api.saveNotes(state.day, ta.value); toast('Notes saved'); } }, 'Save notes'))));
}
function openLogModal(cat, view) {
const metrics = cat.metrics || [];
const values = {};
const body = h('div', {});
body.append(h('p', { class: 'muted' }, `${cat.emoji} ${cat.name}`));
if (!metrics.length) body.append(h('p', { class: 'muted small' }, 'No metrics — this just logs a session.'));
for (const m of metrics) {
values[m.id] = 0;
const valEl = h('span', { class: 'val' }, '0');
const set = (n) => { values[m.id] = Math.max(0, n); valEl.textContent = values[m.id]; };
body.append(h('div', { class: 'metric' },
h('label', {}, m.name),
h('div', { class: 'stepper' },
h('button', { onclick: () => set(values[m.id] - (m.step || 1)) }, ''),
valEl,
h('button', { onclick: () => set(values[m.id] + (m.step || 1)) }, '+'),
h('span', { class: 'unit' }, m.unit || ''))));
}
body.append(h('div', { class: 'btn-row' },
h('button', { class: 'btn-primary big', onclick: async () => {
await api.logEntry({
day: state.day, category_id: cat.id,
values: metrics.map((m) => ({ metric_id: m.id, value: values[m.id] })),
});
closeModal();
toast(`${cat.emoji} ${cat.name} logged!`);
renderToday(view);
refreshStatsIfActive();
} }, 'Log it! 🎉')));
openModal(`Log ${cat.name}`, body);
}
// ---------- PLAN ----------
async function renderPlan(view) {
const from = todayISO(); const to = addDays(from, 13);
const plans = await api.plans(from, to);
const byDay = {};
for (const p of plans) (byDay[p.day] ||= []).push(p);
view.innerHTML = '';
view.append(h('div', { class: 'card' },
h('h2', {}, '🗓️ Plan your week'),
h('p', { class: 'muted small' }, 'Pick what to work on each day. You can always change your mind!')));
for (let i = 0; i < 14; i++) {
const day = addDays(from, i);
const dayPlans = byDay[day] || [];
const plannedIds = new Set(dayPlans.map((p) => p.category_id));
view.append(h('div', { class: 'card' },
h('div', { class: 'row spread' },
h('strong', {}, prettyDay(day)),
h('span', { class: 'badge' }, `${plannedIds.size} planned`)),
h('div', { class: 'pill-grid', style: 'margin-top:10px' },
state.categories.map((c) => {
const on = plannedIds.has(c.id);
const style = on ? `background:${c.color};border-color:${c.color};color:${textColorFor(c.color)}` : '';
return h('button', { class: 'pill' + (on ? ' selected' : ''), style, onclick: async () => {
if (on) {
const p = dayPlans.find((x) => x.category_id === c.id);
await api.deletePlan(p.id);
} else {
await api.addPlan({ day, category_id: c.id });
}
renderPlan(view);
} }, h('span', { class: 'emoji' }, c.emoji), c.name);
}))));
}
}
// ---------- GOALS ----------
async function renderGoals(view) {
const stats = await api.stats();
view.innerHTML = '';
view.append(h('div', { class: 'row spread' },
h('h2', {}, '🏆 Goals'),
h('button', { class: 'btn-primary', onclick: () => openGoalModal(view) }, '+ New goal')));
if (!stats.goals.length) {
view.append(h('div', { class: 'empty' }, h('span', { class: 'big-emoji' }, '🎯'), 'No goals yet. Add one to chase!'));
return;
}
for (const g of stats.goals) {
view.append(goalCard(g, view));
}
}
function goalCard(g, view) {
const done = g.pct >= 100;
const catName = g.category_id ? (catById(g.category_id)?.name || '') : 'Overall';
return h('div', { class: 'card goal' },
h('div', { class: 'top' },
h('span', { class: 'label' }, (g.is_main ? '⭐ ' : '') + (g.label || catName)),
h('span', { class: 'muted small' }, `${Math.round(g.current)} / ${g.target}`)),
h('div', { class: 'muted small' }, `${catName} · ${kindLabel(g.kind)}${g.reward ? ' · 🎁 ' + g.reward : ''}`),
h('div', { class: 'bar' + (done ? ' done' : '') }, h('span', { style: `width:${g.pct}%` })),
h('div', { class: 'btn-row' },
h('button', { class: 'btn-danger', onclick: async () => { if (confirm('Delete this goal?')) { await api.deleteGoal(g.id); renderGoals(view); } } }, 'Delete')));
}
const kindLabel = (k) => ({ session_count: 'Times trained', metric_best: 'Personal best', metric_total: 'Total amount' }[k] || k);
function openGoalModal(view) {
const body = h('div', {});
const labelIn = h('input', { placeholder: 'Goal name (e.g. Juggle 100 in a row)' });
const scopeSel = h('select', {}, h('option', { value: 'category' }, 'A category'), h('option', { value: 'overall' }, 'Overall (all training)'));
const catSel = h('select', {}, state.categories.map((c) => h('option', { value: c.id }, `${c.emoji} ${c.name}`)));
const kindSel = h('select', {},
h('option', { value: 'session_count' }, 'Number of times trained'),
h('option', { value: 'metric_best' }, 'Personal best (one session)'),
h('option', { value: 'metric_total' }, 'Total added up'));
const metricSel = h('select', {});
const targetIn = h('input', { type: 'number', min: '1', placeholder: 'Target number' });
const rewardIn = h('input', { placeholder: 'Reward (optional)' });
const mainChk = h('input', { type: 'checkbox' });
const catField = h('div', { class: 'field' }, h('label', {}, 'Category'), catSel);
const metricField = h('div', { class: 'field' }, h('label', {}, 'Which metric?'), metricSel);
const refreshMetrics = () => {
const c = catById(Number(catSel.value));
metricSel.innerHTML = '';
(c?.metrics || []).forEach((m) => metricSel.append(h('option', { value: m.id }, `${m.name} (${m.unit || ''})`)));
};
const refreshVis = () => {
catField.style.display = scopeSel.value === 'category' ? '' : 'none';
const needMetric = kindSel.value !== 'session_count' && scopeSel.value === 'category';
metricField.style.display = needMetric ? '' : 'none';
if (scopeSel.value === 'overall') kindSel.value = 'session_count';
};
scopeSel.addEventListener('change', refreshVis);
kindSel.addEventListener('change', refreshVis);
catSel.addEventListener('change', refreshMetrics);
refreshMetrics(); refreshVis();
body.append(
h('div', { class: 'field' }, h('label', {}, 'Goal name'), labelIn),
h('div', { class: 'field' }, h('label', {}, 'Track…'), scopeSel),
catField,
h('div', { class: 'field' }, h('label', {}, 'Goal type'), kindSel),
metricField,
h('div', { class: 'field' }, h('label', {}, 'Target'), targetIn),
h('div', { class: 'field' }, h('label', {}, 'Reward'), rewardIn),
h('label', { class: 'row', style: 'gap:10px' }, mainChk, ' Make this the ⭐ main goal (thermometer)'),
h('div', { class: 'btn-row' },
h('button', { class: 'btn-primary big', onclick: async () => {
const payload = {
label: labelIn.value, scope: scopeSel.value, kind: kindSel.value,
target: Number(targetIn.value), reward: rewardIn.value, is_main: mainChk.checked,
};
if (scopeSel.value === 'category') payload.category_id = Number(catSel.value);
if (kindSel.value !== 'session_count') payload.metric_id = Number(metricSel.value);
try { await api.addGoal(payload); closeModal(); renderGoals(view); toast('Goal added 🏆'); }
catch (e) { alert(e.message); }
} }, 'Save goal')));
openModal('New goal', body);
}
// ---------- SETTINGS ----------
function openSettings() {
const body = h('div', {});
body.append(h('h3', {}, 'Categories'));
const list = h('div', {});
const renderList = () => {
list.innerHTML = '';
for (const c of state.categories) {
list.append(h('div', { class: 'entry' },
h('span', { class: 'emoji' }, c.emoji),
h('div', { style: 'flex:1' }, c.name,
h('div', { class: 'vals' }, (c.metrics || []).map((m) => m.name).join(', '))),
h('button', { class: 'btn-danger', onclick: async () => { await api.updateCategory(c.id, { archived: 1 }); await loadCategories(); renderList(); refreshActive(); } }, 'Archive')));
}
};
renderList();
body.append(list);
// Add category form
const nameIn = h('input', { placeholder: 'New category name' });
const emojiIn = h('input', { placeholder: 'Emoji', value: '⚽', maxlength: '4', style: 'max-width:90px' });
const colorIn = h('input', { type: 'color', value: '#EF0107', style: 'max-width:60px;height:48px;padding:4px' });
const metricName = h('input', { placeholder: 'Metric (e.g. Minutes)', value: 'Minutes' });
const metricKind = h('select', {}, h('option', { value: 'duration' }, 'Time'), h('option', { value: 'count' }, 'Count'), h('option', { value: 'score' }, 'Score'));
body.append(h('div', { class: 'card', style: 'margin-top:16px' },
h('h3', {}, ' Add a category'),
h('div', { class: 'field' }, h('label', {}, 'Name'), nameIn),
h('div', { class: 'row', style: 'gap:10px' }, emojiIn, colorIn),
h('div', { class: 'field', style: 'margin-top:10px' }, h('label', {}, 'First metric'), h('div', { class: 'row', style: 'gap:10px' }, metricName, metricKind)),
h('button', { class: 'btn-primary', onclick: async () => {
if (!nameIn.value.trim()) return alert('Enter a name');
await api.addCategory({ name: nameIn.value.trim(), emoji: emojiIn.value || '⚽', color: colorIn.value,
metrics: [{ name: metricName.value || 'Value', unit: metricKind.value === 'duration' ? 'min' : '', kind: metricKind.value, step: metricKind.value === 'duration' ? 5 : 1 }] });
await loadCategories(); renderList(); nameIn.value = ''; refreshActive(); toast('Category added');
} }, 'Add category')));
// Password change
const cur = h('input', { type: 'password', placeholder: 'Current password' });
const nxt = h('input', { type: 'password', placeholder: 'New password' });
body.append(h('div', { class: 'card' },
h('h3', {}, '🔒 Change password'),
h('div', { class: 'field' }, cur), h('div', { class: 'field' }, nxt),
h('button', { class: 'btn-ghost', onclick: async () => {
try { await api.setPassword(cur.value, nxt.value); toast('Password changed'); cur.value = nxt.value = ''; }
catch (e) { alert(e.message); }
} }, 'Update password')));
body.append(h('button', { class: 'btn-danger', style: 'margin-top:8px', onclick: async () => { await api.logout(); location.href = '/login.html'; } }, 'Log out'));
openModal('⚙️ Settings', body, { onClose: refreshActive });
}
// ---------- router ----------
function refreshActive() { switchTab(state.tab); }
function refreshStatsIfActive() { if (state.tab === 'stats') switchTab('stats'); }
const view = document.getElementById('view');
function switchTab(tab) {
state.tab = tab;
document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tab));
view.innerHTML = '<p class="muted center">Loading…</p>';
if (tab === 'today') renderToday(view);
else if (tab === 'plan') renderPlan(view);
else if (tab === 'stats') renderStats(view, { catById });
else if (tab === 'goals') renderGoals(view);
}
document.querySelectorAll('.tab').forEach((t) => t.addEventListener('click', () => switchTab(t.dataset.tab)));
document.getElementById('settings-btn').addEventListener('click', openSettings);
// ---------- boot ----------
(async () => {
try { await api.me(); } catch { return; }
await loadCategories();
switchTab('today');
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
})();