2026-05-08 09:46:43 +00:00
|
|
|
import { state, savePrefs } from './state.js';
|
|
|
|
|
import { tr } from './i18n.js';
|
|
|
|
|
import { api } from './api.js';
|
|
|
|
|
import { tOff, o2d, dTot, fd, fs, editable, now, heuteColor, isCollapsed, toggleCollapse, calc, dcls, dlbl } from './goals.js';
|
|
|
|
|
import { tpl, showToast } from './ui.js';
|
|
|
|
|
|
|
|
|
|
function saveGoal(g) {
|
|
|
|
|
api('PATCH', 'goals/' + g.id, {
|
|
|
|
|
name: g.name, unit: g.unit, daily: g.daily, days: g.days, start: g.start, sets: g.sets,
|
|
|
|
|
}).catch(e => {
|
|
|
|
|
if (e.status !== 401) showToast(tr('errSave'));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addSet(gid, off) {
|
|
|
|
|
const g = state.goals.find(x => x.id === gid);
|
|
|
|
|
if (!g || !editable(g, off)) return;
|
|
|
|
|
const k = gid + '_' + off, amt = parseInt(state.addAmt[k] || '0', 10);
|
|
|
|
|
if (amt <= 0) return;
|
|
|
|
|
if (!g.sets[String(off)]) g.sets[String(off)] = [];
|
|
|
|
|
g.sets[String(off)].push({ amount: amt, time: off === tOff(g) ? now() : '—' });
|
|
|
|
|
state.addAmt[k] = ''; saveGoal(g); render();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function remSet(gid, off, idx) {
|
|
|
|
|
const g = state.goals.find(x => x.id === gid);
|
|
|
|
|
if (!g || !editable(g, off)) return;
|
|
|
|
|
g.sets[String(off)].splice(idx, 1); saveGoal(g); render();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function delGoal(id) {
|
|
|
|
|
if (!confirm(tr('confirmDelete'))) return;
|
|
|
|
|
state.goals = state.goals.filter(g => g.id !== id);
|
|
|
|
|
render();
|
|
|
|
|
api('DELETE', 'goals/' + id).catch(() => showToast(tr('errDelete')));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function selD(gid, off) {
|
|
|
|
|
const g = state.goals.find(x => x.id === gid);
|
2026-05-11 17:04:36 +00:00
|
|
|
if (!g) return;
|
2026-05-08 09:46:43 +00:00
|
|
|
state.selDay[gid] = state.selDay[gid] === off ? null : off;
|
|
|
|
|
render();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startRen(id) {
|
|
|
|
|
const g = state.goals.find(x => x.id === id); if (!g) return;
|
|
|
|
|
state.renamingId = id; state.renameVal = g.name; render();
|
|
|
|
|
setTimeout(() => { const el = document.getElementById('ri' + id); if (el) { el.focus(); el.select(); } }, 50);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function commitRen(id) {
|
|
|
|
|
const g = state.goals.find(x => x.id === id);
|
|
|
|
|
if (g && state.renameVal.trim()) { g.name = state.renameVal.trim(); saveGoal(g); }
|
|
|
|
|
state.renamingId = null; render();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cancelRen() { state.renamingId = null; render(); }
|
|
|
|
|
|
|
|
|
|
function buildNameWrap(g) {
|
|
|
|
|
if (state.renamingId === g.id) {
|
|
|
|
|
const el = tpl('tpl-name-edit');
|
|
|
|
|
const inp = el.querySelector('.ren-input');
|
|
|
|
|
inp.id = 'ri' + g.id; inp.value = g.name; inp.dataset.g = g.id;
|
|
|
|
|
return el;
|
|
|
|
|
}
|
|
|
|
|
const el = tpl('tpl-name-view');
|
|
|
|
|
el.querySelector('.goal-name').textContent = g.name;
|
|
|
|
|
el.querySelector('.btn-ren').dataset.g = g.id;
|
|
|
|
|
return el;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildPanel(g, off) {
|
2026-05-11 17:04:36 +00:00
|
|
|
const t = tOff(g), sets = g.sets[String(off)] || [], tot = dTot(g, off), ed = editable(g, off);
|
|
|
|
|
const lbl = off === t ? tr('heute') : off === t - 1 ? tr('gestern') : null;
|
|
|
|
|
const k = g.id + '_' + off;
|
2026-05-08 09:46:43 +00:00
|
|
|
const el = tpl('tpl-panel');
|
2026-05-11 17:04:36 +00:00
|
|
|
el.querySelector('.dpanel-title').textContent = (lbl ? lbl + ' — ' : '') + fd(o2d(g, off));
|
2026-05-08 09:46:43 +00:00
|
|
|
el.querySelector('.dpanel-sub').textContent = tot + ' / ' + g.daily + ' ' + g.unit;
|
|
|
|
|
const body = el.querySelector('.dpanel-body');
|
|
|
|
|
if (sets.length) {
|
|
|
|
|
for (let i = 0; i < sets.length; i++) {
|
|
|
|
|
const s = sets[i], row = tpl('tpl-set-row'), span = row.querySelector('span');
|
|
|
|
|
if (s.time !== '—') {
|
|
|
|
|
const st = document.createElement('span'); st.className = 'stime'; st.textContent = s.time + ' ·';
|
|
|
|
|
span.appendChild(st); span.appendChild(document.createTextNode(' '));
|
|
|
|
|
}
|
|
|
|
|
const strong = document.createElement('strong'); strong.textContent = s.amount;
|
|
|
|
|
span.appendChild(strong); span.appendChild(document.createTextNode(' ' + g.unit));
|
|
|
|
|
const btn = row.querySelector('.sdel');
|
2026-05-11 17:04:36 +00:00
|
|
|
if (ed) { btn.dataset.g = g.id; btn.dataset.o = off; btn.dataset.i = i; } else { btn.remove(); }
|
2026-05-08 09:46:43 +00:00
|
|
|
body.appendChild(row);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
body.appendChild(tpl('tpl-nosets'));
|
|
|
|
|
}
|
2026-05-11 17:04:36 +00:00
|
|
|
if (ed) {
|
|
|
|
|
const addRow = tpl('tpl-add-row');
|
|
|
|
|
const inp = addRow.querySelector('.num-in');
|
|
|
|
|
inp.placeholder = g.daily; inp.value = state.addAmt[k] || ''; inp.dataset.k = k; inp.dataset.g = g.id; inp.dataset.o = off;
|
|
|
|
|
const abtn = addRow.querySelector('.btn-as');
|
|
|
|
|
abtn.dataset.g = g.id; abtn.dataset.o = off;
|
|
|
|
|
addRow.querySelector('.ulbl').textContent = g.unit;
|
|
|
|
|
body.appendChild(addRow);
|
|
|
|
|
}
|
2026-05-08 09:46:43 +00:00
|
|
|
return el;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildCard(g) {
|
|
|
|
|
const c = calc(g), t = c.tOff;
|
2026-05-11 17:04:36 +00:00
|
|
|
const fc = c.surplus > 0 ? 'var(--blue)' : c.st === 0 && c.buf >= 0 ? 'var(--green)' : c.dailyDelta <= 0 ? 'var(--green)' : c.dailyDelta <= g.daily * .2 ? 'var(--amber)' : 'var(--red)';
|
2026-05-08 09:46:43 +00:00
|
|
|
let bc, bt;
|
|
|
|
|
const bufStr = (c.buf > 0 ? '+' : '') + c.buf;
|
|
|
|
|
if (c.ok && c.surplus > 0) { bc = 'b-buf'; bt = bufStr; }
|
2026-05-11 17:04:36 +00:00
|
|
|
else if (c.ok && c.buf >= 0) { bc = 'b-done'; bt = bufStr; }
|
2026-05-08 09:46:43 +00:00
|
|
|
else if (c.dailyDelta <= 0) { bc = 'b-ok'; bt = bufStr; }
|
|
|
|
|
else if (c.dailyDelta <= g.daily * .2) { bc = 'b-warn'; bt = bufStr; }
|
|
|
|
|
else { bc = 'b-danger'; bt = bufStr; }
|
|
|
|
|
|
|
|
|
|
let el;
|
|
|
|
|
if (isCollapsed(g.id)) {
|
|
|
|
|
el = tpl('tpl-card-collapsed');
|
|
|
|
|
if (c.ok) el.classList.add('done');
|
|
|
|
|
el.querySelector('.card-hdr').dataset.g = g.id;
|
|
|
|
|
const bd = el.querySelector('.card-bd');
|
|
|
|
|
bd.insertBefore(buildNameWrap(g), bd.firstElementChild);
|
|
|
|
|
const hc = heuteColor(c.tdone, g.daily);
|
|
|
|
|
el.querySelector('.m-dr').textContent = c.dr;
|
|
|
|
|
el.querySelector('.m-end').textContent = fs(c.end);
|
|
|
|
|
const mH = el.querySelector('.m-heute'); mH.textContent = c.tdone + '/' + g.daily; mH.style.color = hc;
|
|
|
|
|
el.querySelector('.m-total').textContent = c.done + '/' + c.tot;
|
|
|
|
|
const badge = el.querySelector('.badge'); badge.className = 'badge ' + bc; badge.textContent = bt;
|
|
|
|
|
const fill = el.querySelector('.prog-fill'); fill.style.width = c.pct + '%'; fill.style.background = fc;
|
|
|
|
|
return el;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
el = tpl('tpl-card-expanded');
|
|
|
|
|
if (c.ok) el.classList.add('done');
|
|
|
|
|
el.querySelector('.card-hdr').dataset.g = g.id;
|
|
|
|
|
const bd = el.querySelector('.card-bd');
|
|
|
|
|
bd.insertBefore(buildNameWrap(g), bd.firstElementChild);
|
|
|
|
|
el.querySelector('.m-dr').textContent = c.dr;
|
|
|
|
|
el.querySelector('.m-end').textContent = fs(c.end);
|
|
|
|
|
const badge = el.querySelector('.badge'); badge.className = 'badge ' + bc; badge.textContent = bt;
|
|
|
|
|
const fill = el.querySelector('.prog-fill'); fill.style.width = c.pct + '%'; fill.style.background = fc;
|
|
|
|
|
el.querySelector('.pr-done').textContent = c.done + ' ' + g.unit + ' ' + tr('doneLabel');
|
|
|
|
|
el.querySelector('.pr-pct').textContent = c.pct + '% ' + tr('ofLabel') + ' ' + c.tot;
|
|
|
|
|
el.querySelector('.sv-tdone').textContent = c.tdone;
|
|
|
|
|
el.querySelector('.sv-daily').textContent = g.daily;
|
|
|
|
|
el.querySelector('.sv-st').textContent = c.st;
|
|
|
|
|
el.querySelector('.sv-noch').style.color = heuteColor(c.tdone, g.daily);
|
|
|
|
|
el.querySelectorAll('.sunit').forEach(u => { u.textContent = g.unit; });
|
|
|
|
|
|
|
|
|
|
const sel = state.selDay[g.id] != null ? state.selDay[g.id] : t;
|
|
|
|
|
const dotsWrap = el.querySelector('.dots-wrap');
|
|
|
|
|
for (let i = 0; i < g.days; i++) {
|
|
|
|
|
const it = i === t, iy = i === t - 1, is = sel === i, ed = editable(g, i);
|
|
|
|
|
const dot = tpl('tpl-dot');
|
|
|
|
|
dot.className = dcls(g, i) + (is ? ' rs' : it ? ' rt' : iy && t > 0 ? ' ry' : '');
|
2026-05-11 17:04:36 +00:00
|
|
|
if (i <= t) { dot.dataset.g = g.id; dot.dataset.d = i; }
|
2026-05-08 09:46:43 +00:00
|
|
|
dot.textContent = dlbl(g, i);
|
|
|
|
|
dotsWrap.appendChild(dot);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sel != null) el.insertBefore(buildPanel(g, sel), el.querySelector('.card-foot'));
|
|
|
|
|
el.querySelector('.btn-del').dataset.g = g.id;
|
|
|
|
|
return el;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildQuickBook() {
|
|
|
|
|
const active = state.goals.filter(g => {
|
|
|
|
|
const c = calc(g);
|
|
|
|
|
return tOff(g) < g.days && (c.buf < 0 || (c.tdone < g.daily && c.buf < g.daily));
|
|
|
|
|
});
|
|
|
|
|
if (!active.length) return null;
|
|
|
|
|
const frag = document.createDocumentFragment();
|
|
|
|
|
const lbl = document.createElement('div'); lbl.className = 'sec-lbl'; lbl.textContent = tr('qbLabel');
|
|
|
|
|
frag.appendChild(lbl);
|
|
|
|
|
const card = document.createElement('div'); card.className = 'card qb-card';
|
|
|
|
|
for (const g of active) {
|
|
|
|
|
const c = calc(g), k = g.id + '_' + c.tOff;
|
|
|
|
|
const row = tpl('tpl-qb-row');
|
|
|
|
|
row.querySelector('.qb-name').textContent = g.name;
|
|
|
|
|
const stat = row.querySelector('.qb-stat'); stat.textContent = c.tdone + '/' + g.daily; stat.style.color = heuteColor(c.tdone, g.daily);
|
|
|
|
|
const inp = row.querySelector('.num-in');
|
|
|
|
|
inp.placeholder = g.daily; inp.value = state.addAmt[k] || ''; inp.dataset.k = k; inp.dataset.g = g.id; inp.dataset.o = c.tOff;
|
|
|
|
|
const btn = row.querySelector('.btn-as'); btn.dataset.g = g.id; btn.dataset.o = c.tOff;
|
|
|
|
|
card.appendChild(row);
|
|
|
|
|
}
|
|
|
|
|
frag.appendChild(card);
|
|
|
|
|
return frag;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function calcAwards() {
|
|
|
|
|
let units = 0;
|
|
|
|
|
for (const g of state.goals) {
|
|
|
|
|
if (tOff(g) >= g.days) units += Math.floor(g.days / 30);
|
|
|
|
|
}
|
|
|
|
|
const gold = Math.floor(units / 25); units %= 25;
|
|
|
|
|
const silver = Math.floor(units / 5); const bronze = units % 5;
|
|
|
|
|
return { gold, silver, bronze };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function render() {
|
|
|
|
|
const m = document.getElementById('main');
|
|
|
|
|
const frag = document.createDocumentFragment();
|
|
|
|
|
|
|
|
|
|
if (!state.prefs.hd) {
|
|
|
|
|
const hint = tpl('tpl-hint');
|
|
|
|
|
hint.querySelector('.hclose').onclick = () => { state.prefs.hd = 1; savePrefs(); hint.remove(); };
|
|
|
|
|
frag.appendChild(hint);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const aw = calcAwards();
|
|
|
|
|
if (aw.gold || aw.silver || aw.bronze) {
|
|
|
|
|
const awards = document.createElement('div'); awards.className = 'awards';
|
|
|
|
|
for (const [emoji, count] of [['🥇', aw.gold], ['🥈', aw.silver], ['🥉', aw.bronze]]) {
|
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
|
|
|
const sp = document.createElement('span'); sp.className = 'aw'; sp.textContent = emoji;
|
|
|
|
|
awards.appendChild(sp);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
frag.appendChild(awards);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!state.goals.length) {
|
|
|
|
|
frag.appendChild(tpl('tpl-empty'));
|
|
|
|
|
m.innerHTML = ''; m.appendChild(frag); wire(); return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (state.userName) {
|
|
|
|
|
const gr = document.createElement('div'); gr.className = 'greeting';
|
|
|
|
|
gr.textContent = tr('hello').replace('{n}', state.userName);
|
|
|
|
|
frag.appendChild(gr);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const qb = buildQuickBook(); if (qb) frag.appendChild(qb);
|
|
|
|
|
|
|
|
|
|
const open = [], done = [];
|
|
|
|
|
for (const g of state.goals) {
|
|
|
|
|
if (calc(g).ok) done.push(g); else open.push(g);
|
|
|
|
|
}
|
|
|
|
|
if (open.length) {
|
|
|
|
|
const sl = document.createElement('div'); sl.className = 'sec-lbl'; sl.textContent = tr('openLabel');
|
|
|
|
|
frag.appendChild(sl);
|
|
|
|
|
for (const g of open) frag.appendChild(buildCard(g));
|
|
|
|
|
}
|
|
|
|
|
if (done.length) {
|
|
|
|
|
const sl2 = document.createElement('div'); sl2.className = 'sec-lbl'; sl2.textContent = tr('doneToday');
|
|
|
|
|
frag.appendChild(sl2);
|
|
|
|
|
for (const g of done) frag.appendChild(buildCard(g));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m.innerHTML = ''; m.appendChild(frag); wire();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function wire() {
|
|
|
|
|
document.querySelectorAll('.card-hdr[data-g]').forEach(el => {
|
|
|
|
|
el.onclick = function(e) {
|
|
|
|
|
if (e.target.classList.contains('btn-ren') || e.target.classList.contains('ren-input')) return;
|
|
|
|
|
toggleCollapse(this.dataset.g); render();
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
document.querySelectorAll('.btn-ren').forEach(b => {
|
|
|
|
|
b.onclick = function(e) { e.stopPropagation(); startRen(this.dataset.g); };
|
|
|
|
|
});
|
|
|
|
|
document.querySelectorAll('.ren-input').forEach(inp => {
|
|
|
|
|
const gid = inp.dataset.g;
|
|
|
|
|
inp.oninput = function() { state.renameVal = this.value; };
|
|
|
|
|
inp.onkeydown = function(e) { if (e.key === 'Enter') commitRen(gid); if (e.key === 'Escape') cancelRen(); };
|
|
|
|
|
inp.onblur = function() { commitRen(gid); };
|
|
|
|
|
});
|
2026-05-11 17:04:36 +00:00
|
|
|
document.querySelectorAll('.de, .dl').forEach(d => {
|
2026-05-08 09:46:43 +00:00
|
|
|
d.onclick = function(e) { e.stopPropagation(); selD(this.dataset.g, parseInt(this.dataset.d, 10)); };
|
|
|
|
|
});
|
|
|
|
|
document.querySelectorAll('.btn-as').forEach(b => {
|
|
|
|
|
b.onclick = function() { addSet(this.dataset.g, parseInt(this.dataset.o, 10)); };
|
|
|
|
|
});
|
|
|
|
|
document.querySelectorAll('.num-in').forEach(inp => {
|
|
|
|
|
const k = inp.dataset.k, g = inp.dataset.g, o = parseInt(inp.dataset.o, 10);
|
|
|
|
|
inp.oninput = function() { state.addAmt[k] = this.value; };
|
|
|
|
|
inp.onkeydown = function(e) { if (e.key === 'Enter') addSet(g, o); };
|
|
|
|
|
});
|
|
|
|
|
document.querySelectorAll('.sdel').forEach(b => {
|
|
|
|
|
b.onclick = function() { remSet(this.dataset.g, parseInt(this.dataset.o, 10), parseInt(this.dataset.i, 10)); };
|
|
|
|
|
});
|
|
|
|
|
document.querySelectorAll('.btn-del').forEach(b => {
|
|
|
|
|
b.onclick = function() { delGoal(this.dataset.g); };
|
|
|
|
|
});
|
|
|
|
|
}
|